diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 61d79a29b..44f2217af 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -147,6 +147,8 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e |`appium:autoWebview`| Move directly into Webview context if available. Default `false`|`true`, `false`| |`appium:skipTriggerInputEventAfterSendkeys`| If this capability is set to `true`, then whenever you call the Send Keys method in a web context, the driver will not fire an additional `input` event on the input field used for the call. This event, turned on by default, helps in situations where JS frameworks (like React) do not respond to the input events that occur by default when the underlying Selenium atom is executed. Default `false`|`true`, `false`| |`appium:sendKeyStrategy`| If this capability is set to `oneByOne`, then whenever you call the Send Keys method in a web context, the driver will type each character the given string consists of in serial order to the element. This strategy helps in situations where JS frameworks (like React) update the view for each input. If `appium:skipTriggerInputEventAfterSendkeys` capability is `true`, it will affect every type. For example, when you are going to type the word `appium` with `oneByOne` strategy and `appium:skipTriggerInputEventAfterSendkeys` is enabled, the `appium:skipTriggerInputEventAfterSendkeys` option affects each typing action: `a`, `p`, `p`,`i`, `u` and `m`. Suppose any other value or no value has been provided to the `appium:sendKeyStrategy` capability. In that case, the driver types the given string in the destination input element. `appium` Send Keys input types `appium` if `oneByOne` was not set. |`oneByOne`| +|`appium:showSafariConsoleLog`| Adds Safari JavaScript console events to Appium server logs (`true`) and writes fully serialized events into the `safariConsole` logs bucket (both `true` and `false`). If unset then no console events are being collected, which helps to save CPU and memory resources. Before the driver version 7.22 the default behavior was to always collect console logs if the capability is not set. Setting the value to `false` mimics that legacy behavior. |`true`, `false`| +|`appium:showSafariNetworkLog`| Adds Safari network events to Appium server logs (`true`) and writes fully serialized events into the `safariNetwork` logs bucket (both `true` and `false`). If unset then no network events are being collected, which helps to save CPU and memory resources. Before the driver version 7.22 the default behavior was to always collect network logs if the capability is not set. Setting the value to `false` mimics that legacy behavior. |`true`, `false`| ### Other diff --git a/lib/commands/context.js b/lib/commands/context.js index 80a02f491..3bd707eb5 100644 --- a/lib/commands/context.js +++ b/lib/commands/context.js @@ -569,12 +569,12 @@ const commands = { if (name && name !== NATIVE_WIN && this.logs) { if (this.logs.safariConsole) { await this.remote.startConsole( - this.logs.safariConsole.addLogLine.bind(this.logs.safariConsole), + this.logs.safariConsole.onConsoleLogEvent.bind(this.logs.safariConsole), ); } if (this.logs.safariNetwork) { await this.remote.startNetwork( - this.logs.safariNetwork.addLogLine.bind(this.logs.safariNetwork), + this.logs.safariNetwork.onNetworkEvent.bind(this.logs.safariNetwork), ); } } diff --git a/lib/commands/log.js b/lib/commands/log.js index c5d4e6947..8d05fb1d0 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -60,6 +60,12 @@ const SUPPORTED_LOG_TYPES = { }, }; +const LOG_NAMES_TO_CAPABILITY_NAMES_MAP = { + safariConsole: 'showSafariConsoleLog', + safariNetwork: 'showSafariNetworkLog', + enablePerformanceLogging: 'enablePerformanceLogging', +}; + export default { supportedLogTypes: SUPPORTED_LOG_TYPES, /** @@ -77,11 +83,18 @@ export default { // If logs captured successfully send response with data, else send error const logObject = logsContainer[logType]; - const logs = logObject ? await logObject.getLogs() : null; - if (logs) { - return logs; + if (logObject) { + return await logObject.getLogs(); + } + if (logType in LOG_NAMES_TO_CAPABILITY_NAMES_MAP) { + throw new Error( + `${logType} logs are not enabled. Make sure you've set a proper value ` + + `to the 'appium:${LOG_NAMES_TO_CAPABILITY_NAMES_MAP[logType]}' capability.` + ); } - throw new Error(`No logs of type '${String(logType)}' found.`); + throw new Error( + `No logs of type '${logType}' found. Supported log types are: ${_.keys(SUPPORTED_LOG_TYPES)}.` + ); }, /** @@ -98,23 +111,30 @@ export default { sim: this.device, udid: this.isRealDevice() ? this.opts.udid : undefined, }); - - if (this.isRealDevice()) { - this.logs.syslog = new IOSDeviceLog({ + this.logs.syslog = this.isRealDevice() + ? new IOSDeviceLog({ udid: this.opts.udid, showLogs: this.opts.showIOSLog, log: this.log, - }); - } else { - this.logs.syslog = new IOSSimulatorLog({ + }) + : new IOSSimulatorLog({ sim: /** @type {import('appium-ios-simulator').Simulator} */ (this.device), showLogs: this.opts.showIOSLog, iosSimulatorLogsPredicate: this.opts.iosSimulatorLogsPredicate, log: this.log, }); + if (_.isBoolean(this.opts.showSafariConsoleLog)) { + this.logs.safariConsole = new SafariConsoleLog({ + showLogs: this.opts.showSafariConsoleLog, + log: this.log, + }); + } + if (_.isBoolean(this.opts.showSafariNetworkLog)) { + this.logs.safariNetwork = new SafariNetworkLog({ + showLogs: this.opts.showSafariNetworkLog, + log: this.log, + }); } - this.logs.safariConsole = new SafariConsoleLog(!!this.opts.showSafariConsoleLog); - this.logs.safariNetwork = new SafariNetworkLog(!!this.opts.showSafariNetworkLog); } let didStartSyslog = false; @@ -130,8 +150,6 @@ export default { } })(), this.logs.crashlog.startCapture(), - this.logs.safariConsole.startCapture(), - this.logs.safariNetwork.startCapture(), ]; await B.all(promises); diff --git a/lib/device-log/helpers.ts b/lib/device-log/helpers.ts index 00724ad40..73903e23b 100644 --- a/lib/device-log/helpers.ts +++ b/lib/device-log/helpers.ts @@ -1,9 +1,13 @@ import type { LogEntry } from '../commands/types'; -export function toLogEntry(message: string, timestamp: number): LogEntry { +export const DEFAULT_LOG_LEVEL = 'ALL'; +export const MAX_JSON_LOG_LENGTH = 200; +export const MAX_BUFFERED_EVENTS_COUNT = 5000; + +export function toLogEntry(message: string, timestamp: number, level: string = DEFAULT_LOG_LEVEL): LogEntry { return { timestamp, - level: 'ALL', + level, message, }; } diff --git a/lib/device-log/ios-crash-log.js b/lib/device-log/ios-crash-log.js index e060f6dee..b5d3898a7 100644 --- a/lib/device-log/ios-crash-log.js +++ b/lib/device-log/ios-crash-log.js @@ -105,15 +105,6 @@ class IOSCrashLog { return await this.filesToJSON(diff); } - /** - * @returns {Promise} - */ - async getAllLogs() { - let crashFiles = await this.getCrashes(); - let logFiles = _.difference(crashFiles, this.prevLogs); - return await this.filesToJSON(logFiles); - } - /** * @param {string[]} paths * @returns {Promise} diff --git a/lib/device-log/ios-device-log.ts b/lib/device-log/ios-device-log.ts index c60144d6d..d53bf3981 100644 --- a/lib/device-log/ios-device-log.ts +++ b/lib/device-log/ios-device-log.ts @@ -5,12 +5,12 @@ import type { AppiumLogger } from '@appium/types'; export interface IOSDeviceLogOpts { udid: string; showLogs?: boolean; - log?: AppiumLogger; + log: AppiumLogger; } export class IOSDeviceLog extends LineConsumingLog { - private udid: string; - private showLogs: boolean; + private readonly udid: string; + private readonly showLogs: boolean; private service: any | null; constructor(opts: IOSDeviceLogOpts) { diff --git a/lib/device-log/ios-log.ts b/lib/device-log/ios-log.ts index 3709e5fbe..41910c118 100644 --- a/lib/device-log/ios-log.ts +++ b/lib/device-log/ios-log.ts @@ -18,7 +18,6 @@ export abstract class IOSLog< > extends EventEmitter { private maxBufferSize: number; private logs: LRUCache; - private logIndexSinceLastRequest: number | null; private _log: AppiumLogger; constructor(opts: IOSLogOptions = {}) { @@ -27,7 +26,6 @@ export abstract class IOSLog< this.logs = new LRUCache({ max: this.maxBufferSize, }); - this.logIndexSinceLastRequest = null; this._log = opts.log ?? logger.getLogger(this.constructor.name); } @@ -39,40 +37,12 @@ export abstract class IOSLog< return this._log; } - broadcast(entry: TRawEntry): void { - let recentIndex = -1; - for (const key of this.logs.rkeys()) { - recentIndex = key; - break; - } - const serializedEntry = this._serializeEntry(entry); - this.logs.set(++recentIndex, serializedEntry); - if (this.listenerCount('output')) { - this.emit('output', this._deserializeEntry(serializedEntry)); - } - } - getLogs(): LogEntry[] { const result: LogEntry[] = []; - let recentLogIndex: number | null = null; - for (const [index, value] of this.logs.entries()) { - if (this.logIndexSinceLastRequest && index > this.logIndexSinceLastRequest - || !this.logIndexSinceLastRequest) { - recentLogIndex = index; - result.push(this._deserializeEntry(value)); - } - } - if (recentLogIndex !== null) { - this.logIndexSinceLastRequest = recentLogIndex; - } - return result; - } - - getAllLogs(): LogEntry[] { - const result: LogEntry[] = []; - for (const value of this.logs.values()) { - result.push(this._deserializeEntry(value)); + for (const value of this.logs.rvalues()) { + result.push(this._deserializeEntry(value as TSerializedEntry)); } + this._clearEntries(); return result; } @@ -82,6 +52,19 @@ export abstract class IOSLog< protected _clearEntries() { this.logs.clear(); } + + protected broadcast(entry: TRawEntry): void { + let recentIndex = -1; + for (const key of this.logs.keys()) { + recentIndex = key; + break; + } + const serializedEntry = this._serializeEntry(entry); + this.logs.set(++recentIndex, serializedEntry); + if (this.listenerCount('output')) { + this.emit('output', this._deserializeEntry(serializedEntry)); + } + } } export default IOSLog; diff --git a/lib/device-log/ios-performance-log.ts b/lib/device-log/ios-performance-log.ts index e5fb5e564..97cadfab5 100644 --- a/lib/device-log/ios-performance-log.ts +++ b/lib/device-log/ios-performance-log.ts @@ -1,24 +1,22 @@ import _ from 'lodash'; -import { IOSLog } from './ios-log'; -import type { LogEntry } from '../commands/types'; import type { AppiumLogger } from '@appium/types'; - -const MAX_EVENTS = 5000; +import { MAX_JSON_LOG_LENGTH, MAX_BUFFERED_EVENTS_COUNT } from './helpers'; +import { LineConsumingLog } from './line-consuming-log'; type PerformanceLogEntry = object; export interface IOSPerformanceLogOptions { remoteDebugger: any; maxEvents?: number; - log?: AppiumLogger; + log: AppiumLogger; } -export class IOSPerformanceLog extends IOSLog { - private remoteDebugger: any; +export class IOSPerformanceLog extends LineConsumingLog { + private readonly remoteDebugger: any; private _started: boolean; constructor(opts: IOSPerformanceLogOptions) { super({ - maxBufferSize: opts.maxEvents ?? MAX_EVENTS, + maxBufferSize: opts.maxEvents ?? MAX_BUFFERED_EVENTS_COUNT, log: opts.log, }); this.remoteDebugger = opts.remoteDebugger; @@ -28,33 +26,24 @@ export class IOSPerformanceLog extends IOSLog { this.log.debug('Starting performance (Timeline) log capture'); this._clearEntries(); - const result = await this.remoteDebugger.startTimeline(this.onTimelineEvent.bind(this)); + await this.remoteDebugger.startTimeline(this.onTimelineEvent.bind(this)); this._started = true; - return result; } override async stopCapture(): Promise { this.log.debug('Stopping performance (Timeline) log capture'); - const result = await this.remoteDebugger.stopTimeline(); + await this.remoteDebugger.stopTimeline(); this._started = false; - return result; } override get isCapturing(): boolean { return this._started; } - protected override _serializeEntry(value: PerformanceLogEntry): PerformanceLogEntry { - return value; - } - - protected override _deserializeEntry(value: PerformanceLogEntry): LogEntry { - return value as LogEntry; - } - private onTimelineEvent(event: PerformanceLogEntry): void { - this.log.debug(`Received Timeline event: ${_.truncate(JSON.stringify(event))}`); - this.broadcast(event); + const serializedEntry = JSON.stringify(event); + this.broadcast(serializedEntry); + this.log.debug(`Received Timeline event: ${_.truncate(serializedEntry, {length: MAX_JSON_LOG_LENGTH})}`); } } diff --git a/lib/device-log/ios-simulator-log.ts b/lib/device-log/ios-simulator-log.ts index f39eafb33..be4d2ec9b 100644 --- a/lib/device-log/ios-simulator-log.ts +++ b/lib/device-log/ios-simulator-log.ts @@ -12,13 +12,13 @@ export interface IOSSimulatorLogOptions { sim: Simulator; showLogs?: boolean; iosSimulatorLogsPredicate?: string; - log?: AppiumLogger; + log: AppiumLogger; } export class IOSSimulatorLog extends LineConsumingLog { - private sim: Simulator; - private showLogs: boolean; - private predicate?: string; + private readonly sim: Simulator; + private readonly showLogs: boolean; + private readonly predicate?: string; private proc: SubProcess | null; constructor(opts: IOSSimulatorLogOptions) { @@ -93,24 +93,22 @@ export class IOSSimulatorLog extends LineConsumingLog { } } - private async finishStartingLogCapture() { + private async finishStartingLogCapture(): Promise { if (!this.proc) { throw this.log.errorWithException('Could not capture simulator log'); } for (const streamName of ['stdout', 'stderr']) { - this.proc.on(`lines-${streamName}`, (/** @type {string[]} */ lines) => { - for (const line of lines) { - this.onOutput(line, ...(streamName === 'stderr' ? ['STDERR'] : [])); - } + this.proc.on(`line-${streamName}`, (line: string) => { + this.onOutput(line, ...(streamName === 'stderr' ? ['STDERR'] : [])); }); } - const startDetector = (/** @type {string} */ stdout, /** @type {string} */ stderr) => { + const startDetector = (stdout: string, stderr: string) => { if (EXECVP_ERROR_PATTERN.test(stderr)) { throw new Error('iOS log capture process failed to start'); } - return stdout || stderr; + return Boolean(stdout || stderr); }; await this.proc.start(startDetector, START_TIMEOUT); } diff --git a/lib/device-log/rotating-log.js b/lib/device-log/rotating-log.js deleted file mode 100644 index 5f84e0b1e..000000000 --- a/lib/device-log/rotating-log.js +++ /dev/null @@ -1,65 +0,0 @@ -import _ from 'lodash'; -import {logger} from 'appium/support'; - -const MAX_LOG_ENTRIES_COUNT = 10000; - -class RotatingLog { - constructor(showLogs = false, label = 'Log Label') { - this.log = logger.getLogger(label); - - this.showLogs = showLogs; - this.logs = []; - this.logIdxSinceLastRequest = 0; - - this.isCapturing = false; - } - - // eslint-disable-next-line require-await - async startCapture() { - this.isCapturing = true; - } - - // eslint-disable-next-line require-await - async stopCapture() { - this.isCapturing = false; - } - - /** - * @privateRemarks Subclasses must implement this. - */ - addLogLine() { - throw new Error('Not implemented'); - } - - // eslint-disable-next-line require-await - async getLogs() { - if (this.logs.length && this.logIdxSinceLastRequest < this.logs.length) { - let result = this.logs; - if (this.logIdxSinceLastRequest > 0) { - result = result.slice(this.logIdxSinceLastRequest); - } - this.logIdxSinceLastRequest = this.logs.length; - return result; - } - return []; - } - - // eslint-disable-next-line require-await - async getAllLogs() { - return _.clone(this.logs); - } - - get logs() { - if (!this._logs) { - this.logs = []; - } - return this._logs; - } - - set logs(logs) { - this._logs = logs; - } -} - -export {RotatingLog, MAX_LOG_ENTRIES_COUNT}; -export default RotatingLog; diff --git a/lib/device-log/safari-console-log.js b/lib/device-log/safari-console-log.js deleted file mode 100644 index f5848ff33..000000000 --- a/lib/device-log/safari-console-log.js +++ /dev/null @@ -1,96 +0,0 @@ -import {RotatingLog, MAX_LOG_ENTRIES_COUNT} from './rotating-log'; -import _ from 'lodash'; -import {util} from 'appium/support'; - -class SafariConsoleLog extends RotatingLog { - constructor(showLogs) { - super(showLogs, 'SafariConsole'); - - // js console has `warning` level, so map to `warn` - this.log = new Proxy(this.log, { - get(target, prop, receiver) { - return Reflect.get(target, prop === 'warning' ? 'warn' : prop, receiver); - }, - }); - } - - addLogLine(err, out) { - if (this.isCapturing) { - this.logs = this.logs || []; - while (this.logs.length >= MAX_LOG_ENTRIES_COUNT) { - this.logs.shift(); - if (this.logIdxSinceLastRequest > 0) { - this.logIdxSinceLastRequest--; - } - } - - /* - * The output will be like: - * { - * "source": "javascript", - * "level":"error", - * "text":"ReferenceError: Can't find variable: s_account", - * "type":"log", - * "line":2, - * "column":21, - * "url":"https://assets.adobedtm.com/b46e318d845250834eda10c5a20827c045a4d76f/scripts/satellite-57866f8b64746d53a8000104-staging.js", - * "repeatCount":1, - * "stackTrace":[{ - * "functionName":"global code", - * "url":"https://assets.adobedtm.com/b46e318d845250834eda10c5a20827c045a4d76f/scripts/satellite-57866f8b64746d53a8000104-staging.js", - * "scriptId":"6", - * "lineNumber":2, - * "columnNumber":21 - * }] - * } - * - * we need, at least, `level` (in accordance with Java levels - * (https://docs.oracle.com/javase/7/docs/api/java/util/logging/Level.html)), - * `timestamp`, and `message` to satisfy the java client. In order to - * provide all the information to the client, `message` is the full - * object, stringified. - */ - const entry = { - level: - { - error: 'SEVERE', - warning: 'WARNING', - log: 'FINE', - }[out.level] || 'INFO', - timestamp: Date.now(), - message: JSON.stringify(out), - }; - this.logs.push(entry); - } - - if (_.has(out, 'count')) { - // this is a notification of the previous message being repeated - // this should _never_ be the first message, so the previous one ought to - // be populated. If it is not, nothing will break, it will just look odd - // in the output below (no url or line numbers) - const count = out.count; - out = this._previousOutput || {}; - out.text = `Previous message repeated ${util.pluralize('time', count, true)}`; - } else { - // save the most recent output - this._previousOutput = out; - } - - // format output like - // SafariConsole [WARNING][http://appium.io 2:13] Log something to warn - if (this.showLogs) { - let level = 'debug'; - if (out.level === 'warning' || out.level === 'error') { - level = out.level; - } - for (const line of out.text.split('\n')) { - // url is optional, so get formatting here - const url = out.url ? `${out.url} ` : ''; - this.log[level](`[${level.toUpperCase()}][${url}${out.line}:${out.column}] ${line}`); - } - } - } -} - -export {SafariConsoleLog}; -export default SafariConsoleLog; diff --git a/lib/device-log/safari-console-log.ts b/lib/device-log/safari-console-log.ts new file mode 100644 index 000000000..9cf6ef3c8 --- /dev/null +++ b/lib/device-log/safari-console-log.ts @@ -0,0 +1,112 @@ +import _ from 'lodash'; +import type { AppiumLogger } from '@appium/types'; +import { + toLogEntry, + DEFAULT_LOG_LEVEL, + MAX_JSON_LOG_LENGTH, + MAX_BUFFERED_EVENTS_COUNT +} from './helpers'; +import IOSLog from './ios-log'; +import type { LogEntry } from '../commands/types'; + +const LOG_LEVELS_MAP = { + error: 'SEVERE', + warning: 'WARNING', + log: 'FINE', +}; + +export interface SafariConsoleLogOptions { + showLogs: boolean; + log: AppiumLogger; +} + +export interface SafariConsoleStacktraceEntry { + functionName: string; + url: string; + scriptId: number; + lineNumber: number; + columnNumber: number; +} + +export interface SafariConsoleEntry { + source: string; + level: string; + text: string; + type: string; + line: number; + column: number; + url?: string; + repeatCount: number; + stackTrace: SafariConsoleStacktraceEntry[]; +} + +type TSerializedEntry = [SafariConsoleEntry, number]; + +export class SafariConsoleLog extends IOSLog { + private readonly _showLogs: boolean; + + constructor(opts: SafariConsoleLogOptions) { + super({ + log: opts.log, + maxBufferSize: MAX_BUFFERED_EVENTS_COUNT, + }); + this._showLogs = opts.showLogs; + } + + override async startCapture(): Promise {} + override async stopCapture(): Promise {} + override get isCapturing(): boolean { + return true; + } + + /** + * + * @param err + * @param entry The output will be like: + * { + * "source": "javascript", + * "level":"error", + * "text":"ReferenceError: Can't find variable: s_account", + * "type":"log", + * "line":2, + * "column":21, + * "url":"https://assets.adobedtm.com/b46e318d845250834eda10c5a20827c045a4d76f/scripts/satellite-57866f8b64746d53a8000104-staging.js", + * "repeatCount":1, + * "stackTrace":[{ + * "functionName":"global code", + * "url":"https://assets.adobedtm.com/b46e318d845250834eda10c5a20827c045a4d76f/scripts/satellite-57866f8b64746d53a8000104-staging.js", + * "scriptId":"6", + * "lineNumber":2, + * "columnNumber":21 + * }] + * } + * + * we need, at least, `level` (in accordance with Java levels + * (https://docs.oracle.com/javase/7/docs/api/java/util/logging/Level.html)), + * `timestamp`, and `message` to satisfy the java client. In order to + * provide all the information to the client, `message` is the full + * object, stringified. + * + */ + onConsoleLogEvent(err: object | null, entry: SafariConsoleEntry): void { + this.broadcast(entry); + if (this._showLogs) { + this.log.info(`[SafariConsole] ${_.truncate(JSON.stringify(entry), {length: MAX_JSON_LOG_LENGTH})}`); + } + } + + protected override _serializeEntry(value: SafariConsoleEntry): TSerializedEntry { + return [value, Date.now()]; + } + + protected override _deserializeEntry(value: TSerializedEntry): LogEntry { + const [entry, timestamp] = value; + return toLogEntry(JSON.stringify(entry), timestamp, mapLogLevel(entry.level)); + } +} + +function mapLogLevel(originalLevel: string): string { + return LOG_LEVELS_MAP[originalLevel] ?? DEFAULT_LOG_LEVEL; +} + +export default SafariConsoleLog; diff --git a/lib/device-log/safari-network-log.js b/lib/device-log/safari-network-log.js deleted file mode 100644 index c45506fc4..000000000 --- a/lib/device-log/safari-network-log.js +++ /dev/null @@ -1,193 +0,0 @@ -import _ from 'lodash'; -import URL from 'url'; -import {util} from 'appium/support'; -import {RotatingLog, MAX_LOG_ENTRIES_COUNT} from './rotating-log'; - -class SafariNetworkLog extends RotatingLog { - constructor(showLogs) { - super(showLogs, 'SafariNetwork'); - } - - getEntry(requestId) { - let outputEntry; - while (this.logs.length >= MAX_LOG_ENTRIES_COUNT) { - // pull the first entry, which is the oldest - const entry = this.logs.shift(); - if (entry && entry.requestId === requestId) { - // we are adding to an existing entry, and it was almost removed - // add to the end of the list and try again - outputEntry = entry; - this.logs.push(outputEntry); - continue; - } - // we've removed an element, so the count is down one - if (this.logIdxSinceLastRequest > 0) { - this.logIdxSinceLastRequest--; - } - } - - if (!outputEntry) { - // we do not yes have an entry to associate this bit of output with - // most likely the entry will be at the end of the list, so start there - for (let i = this.logs.length - 1; i >= 0; i--) { - if (this.logs[i].requestId === requestId) { - // found it! - outputEntry = this.logs[i]; - // this is now the most current entry, so remove it from the list - // to be added to the end below - this.logs.splice(i, 1); - break; - } - } - - // nothing has been found, so create a new entry - if (!outputEntry) { - outputEntry = { - requestId, - logs: [], - }; - } - - // finally, add the entry to the end of the list - this.logs.push(outputEntry); - } - - return outputEntry; - } - - addLogLine(method, out) { - if (!this.isCapturing && !this.showLogs) { - // neither capturing nor displaying, so do nothing - return; - } - - if (['Network.dataReceived'].includes(method)) { - // status update, no need to handle - return; - } - - // events we care about: - // Network.requestWillBeSent - // Network.responseReceived - // Network.loadingFinished - // Network.loadingFailed - - const outputEntry = this.getEntry(out.requestId); - if (this.isCapturing) { - // now add the output we just received to the logs for this particular entry - outputEntry.logs = outputEntry.logs || []; - - outputEntry.logs.push(out); - } - - // if we are not displaying the logs, - // or we are not finished getting events for this network call, - // we are done - if (!this.showLogs) { - return; - } - - if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') { - this.printLogLine(outputEntry); - } - } - - getLogDetails(outputEntry) { - // extract the data - const record = outputEntry.logs.reduce(function getRecord(record, entry) { - record.requestId = entry.requestId; - if (entry.response) { - const url = URL.parse(entry.response.url); - // get the last part of the url, along with the query string, if possible - record.name = - `${_.last(String(url.pathname).split('/'))}${url.search ? `?${url.search}` : ''}` || - url.host; - record.status = entry.response.status; - if (entry.response.timing) { - record.time = - entry.response.timing.receiveHeadersEnd || entry.response.timing.responseStart || 0; - } - record.source = entry.response.source; - } - if (entry.type) { - record.type = entry.type; - } - if (entry.initiator) { - record.initiator = entry.initiator; - } - if (entry.metrics) { - // Safari has a `metrics` object on it's `Network.loadingFinished` event - record.size = entry.metrics.responseBodyBytesReceived || 0; - } - if (entry.errorText) { - record.errorText = entry.errorText; - // When a network call is cancelled, Safari returns `cancelled` as error text - // but has a boolean `canceled`. Normalize the two spellings in favor of - // the text, which will also be displayed - record.cancelled = entry.canceled; - } - return record; - }, {}); - - return record; - } - - printLogLine(outputEntry) { - const { - requestId, - name, - status, - type, - initiator = {}, - size = 0, - time = 0, - source, - errorText, - cancelled = false, - } = this.getLogDetails(outputEntry); - - // print out the record, formatted appropriately - this.log.debug(`Network event:`); - this.log.debug(` Id: ${requestId}`); - this.log.debug(` Name: ${name}`); - this.log.debug(` Status: ${status}`); - this.log.debug(` Type: ${type}`); - this.log.debug(` Initiator: ${initiator.type}`); - for (const line of initiator.stackTrace || []) { - const functionName = line.functionName || '(anonymous)'; - - const url = - !line.url || line.url === '[native code]' - ? '' - : `@${_.last((URL.parse(line.url).pathname || '').split('/'))}:${line.lineNumber}`; - this.log.debug(` ${_.padEnd(_.truncate(functionName, {length: 20}), 21)} ${url}`); - } - // get `memory-cache` or `disk-cache`, etc., right - const sizeStr = source.includes('cache') ? ` (from ${source.replace('-', ' ')})` : `${size}B`; - this.log.debug(` Size: ${sizeStr}`); - this.log.debug(` Time: ${Math.round(time)}ms`); - if (errorText) { - this.log.debug(` Error: ${errorText}`); - } - if (util.hasValue(cancelled)) { - this.log.debug(` Cancelled: ${cancelled}`); - } - } - - async getLogs() { - const logs = await super.getLogs(); - // in order to satisfy certain clients, we need to have a basic structure - // to the results, with `level`, `timestamp`, and `message`, which is - // all the information stringified - return logs.map(function adjustEntry(entry) { - return Object.assign({}, entry, { - level: 'INFO', - timestamp: Date.now(), - message: JSON.stringify(entry), - }); - }); - } -} - -export {SafariNetworkLog}; -export default SafariNetworkLog; diff --git a/lib/device-log/safari-network-log.ts b/lib/device-log/safari-network-log.ts new file mode 100644 index 000000000..b3b44734d --- /dev/null +++ b/lib/device-log/safari-network-log.ts @@ -0,0 +1,80 @@ +import _ from 'lodash'; +import { LineConsumingLog } from './line-consuming-log'; +import { MAX_JSON_LOG_LENGTH, MAX_BUFFERED_EVENTS_COUNT } from './helpers'; +import type { AppiumLogger } from '@appium/types'; + +const EVENTS_TO_LOG = [ + 'Network.loadingFinished', + 'Network.loadingFailed', +]; +const MONITORED_EVENTS = [ + 'Network.requestWillBeSent', + 'Network.responseReceived', + ...EVENTS_TO_LOG, +]; + +export interface SafariConsoleLogOptions { + showLogs: boolean; + log: AppiumLogger; +} + +export interface SafariNetworkResponseTiming { + responseStart: number; + receiveHeadersEnd: number; +} + +export interface SafariNetworkResponse { + url: string; + status: number; + timing: SafariNetworkResponseTiming; + source: string; +} + +export interface SafariNetworkLogEntryMetrics { + responseBodyBytesReceived: number; +} + +export interface SafariNetworkLogEntry { + requestId: string; + response?: SafariNetworkResponse; + type?: string; + initiator?: string; + // Safari has a `metrics` object on it's `Network.loadingFinished` event + metrics?: SafariNetworkLogEntryMetrics; + errorText?: string; + // When a network call is cancelled, Safari returns `cancelled` as error text + // but has a boolean `canceled`. + canceled?: boolean; +} + +export class SafariNetworkLog extends LineConsumingLog { + private readonly _showLogs: boolean; + + constructor(opts: SafariConsoleLogOptions) { + super({ + log: opts.log, + maxBufferSize: MAX_BUFFERED_EVENTS_COUNT, + }); + this._showLogs = opts.showLogs; + } + + override async startCapture(): Promise {} + override async stopCapture(): Promise {} + override get isCapturing(): boolean { + return true; + } + + onNetworkEvent(method: string, entry: SafariNetworkLogEntry): void { + if (!MONITORED_EVENTS.includes(method)) { + return; + } + + const serializedEntry = JSON.stringify(entry); + this.broadcast(serializedEntry); + if (this._showLogs && EVENTS_TO_LOG.includes(method)) { + this.log.info(`[SafariNetwork] ${_.truncate(serializedEntry, {length: MAX_JSON_LOG_LENGTH})}`); + } + } +} + +export default SafariNetworkLog; diff --git a/package.json b/package.json index 18c3be373..cf5b40b9c 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "portscanner": "^2.2.0", "semver": "^7.5.4", "source-map-support": "^0.x", - "teen_process": "^2.1.10", + "teen_process": "^2.2.0", "ws": "^8.13.0" }, "scripts": {