|
| 1 | +import {fs} from 'appium/support'; |
| 2 | +import log from './logger'; |
| 3 | +import _ from 'lodash'; |
| 4 | +import slug from 'slug'; |
| 5 | +import B from 'bluebird'; |
| 6 | +import _fs from 'node:fs'; |
| 7 | +import path from 'node:path'; |
| 8 | +import envPaths from 'env-paths'; |
| 9 | +import {lock} from 'proper-lockfile'; |
| 10 | +import {Telnet} from 'telnet-client'; |
| 11 | +import {EventEmitter} from 'node:events'; |
| 12 | + |
| 13 | +/** |
| 14 | + * Debug port on Roku device |
| 15 | + * @see https://developer.roku.com/en-ca/docs/developer-program/debugging/debugging-channels.md |
| 16 | + */ |
| 17 | +export const DEBUG_PORT = 8085; |
| 18 | + |
| 19 | +/** |
| 20 | + * Base lockfile options, sans the lockfile path |
| 21 | + * @internal |
| 22 | + */ |
| 23 | +const BASE_LOCK_OPTS = Object.freeze( |
| 24 | + /** @type {import('proper-lockfile').LockOptions} */ ({ |
| 25 | + realpath: false, |
| 26 | + fs: _fs, |
| 27 | + retries: { |
| 28 | + retries: 3, |
| 29 | + minTimeout: 100, |
| 30 | + maxTimeout: 1000, |
| 31 | + unref: true, |
| 32 | + }, |
| 33 | + }) |
| 34 | +); |
| 35 | + |
| 36 | +const DEFAULT_TELNET_OPTS = Object.freeze( |
| 37 | + /** @type {import('telnet-client').ConnectOptions} */ ({ |
| 38 | + negotiationMandatory: false, |
| 39 | + encoding: 'utf8', |
| 40 | + }) |
| 41 | +); |
| 42 | + |
| 43 | +const DEFAULT_ROKU_DEBUG_OPTS = Object.freeze( |
| 44 | + /** @type {RokuDebugLogOpts} */ ({ |
| 45 | + telnetOpts: DEFAULT_TELNET_OPTS, |
| 46 | + }) |
| 47 | +); |
| 48 | + |
| 49 | +export class RokuDebugLog extends EventEmitter { |
| 50 | + /** |
| 51 | + * User-provided opts |
| 52 | + * @type {RokuDebugLogOpts} |
| 53 | + */ |
| 54 | + #opts; |
| 55 | + |
| 56 | + /** |
| 57 | + * Host of Telnet server |
| 58 | + * @type {string} |
| 59 | + */ |
| 60 | + #host; |
| 61 | + |
| 62 | + /** |
| 63 | + * Host of Telnet server, slugified for use in filenames |
| 64 | + * @type {string} |
| 65 | + */ |
| 66 | + #hostSlug; |
| 67 | + |
| 68 | + /** |
| 69 | + * Directory which will contain debug logs |
| 70 | + * @type {string} |
| 71 | + */ |
| 72 | + #logDirpath; |
| 73 | + |
| 74 | + /** |
| 75 | + * Directory which will contain lockfiles |
| 76 | + * @type {string} |
| 77 | + */ |
| 78 | + #lockfileDirpath; |
| 79 | + |
| 80 | + /** @type {Telnet|undefined} */ |
| 81 | + #telnetClient; |
| 82 | + |
| 83 | + /** |
| 84 | + * If logfile is locked, this will be a wrapped unlock function. |
| 85 | + * |
| 86 | + * @this {null} |
| 87 | + * @type {() => Promise<void>|undefined} |
| 88 | + */ |
| 89 | + #_unlock; |
| 90 | + |
| 91 | + /** |
| 92 | + * Sets class props and determines logfile path |
| 93 | + * @param {string} host |
| 94 | + * @param {RokuDebugLogOpts} [opts] |
| 95 | + */ |
| 96 | + constructor(host, opts = {}) { |
| 97 | + super(); |
| 98 | + this.#host = host; |
| 99 | + this.#opts = _.defaultsDeep(opts, DEFAULT_ROKU_DEBUG_OPTS); |
| 100 | + |
| 101 | + this.#hostSlug = RokuDebugLog.slugify(host); |
| 102 | + |
| 103 | + const {log: logDirpath, temp: lockfileDirpath} = envPaths('appium-roku-driver'); |
| 104 | + this.#logDirpath = logDirpath; |
| 105 | + this.#lockfileDirpath = lockfileDirpath; |
| 106 | + |
| 107 | + if (!this.#opts.logPath) { |
| 108 | + const logFileBasename = `debug-${this.#hostSlug}.log`; |
| 109 | + this.#opts.logPath = path.join(logDirpath, logFileBasename); |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * Slugifies a string in preparation for writing to filesystem |
| 115 | + * |
| 116 | + * May be used by those wanting to provide a custom `logPath` option to the constructor |
| 117 | + * |
| 118 | + * Just a re-export of `slug` module |
| 119 | + * @see https://npm.im/slug |
| 120 | + */ |
| 121 | + static slugify = slug; |
| 122 | + |
| 123 | + /** |
| 124 | + * Path to the logfile |
| 125 | + */ |
| 126 | + get logPath() { |
| 127 | + return this.#opts.logPath; |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * Locks the logfile if we're not using a file descriptor. |
| 132 | + * @param {string} basename |
| 133 | + * @param {string} logPath |
| 134 | + * @param {string} tempDirpath |
| 135 | + * @param {LockLogfileOpts} [opts] |
| 136 | + * @returns {Promise<() => Promise<void>>} - An async unlock function. If we're using a file descriptor, this is a no-op. |
| 137 | + */ |
| 138 | + async #lockLogfile(basename, logPath, tempDirpath, opts = {}) { |
| 139 | + // `prepare-lockfile` does not support file descriptors |
| 140 | + if (_.isNil(opts.fd)) { |
| 141 | + const lockfilePath = path.join(tempDirpath, `${basename}.lock`); |
| 142 | + const originalUnlock = await lock(logPath, {...BASE_LOCK_OPTS, lockfilePath}); |
| 143 | + const wrappedUnlock = async () => { |
| 144 | + try { |
| 145 | + await originalUnlock(); |
| 146 | + log.debug(`Unlocked ${logPath}`); |
| 147 | + } catch (err) { |
| 148 | + // todo handle better |
| 149 | + log.warn(err); |
| 150 | + } |
| 151 | + }; |
| 152 | + return wrappedUnlock; |
| 153 | + } |
| 154 | + return () => B.resolve(); |
| 155 | + } |
| 156 | + |
| 157 | + /** |
| 158 | + * Unlocks lockfile and tries not to leak memory |
| 159 | + */ |
| 160 | + async #cleanup() { |
| 161 | + this.#telnetClient = undefined; |
| 162 | + try { |
| 163 | + await this.#_unlock?.(); |
| 164 | + } finally { |
| 165 | + this.#_unlock = undefined; |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + /** |
| 170 | + * Prepares filesystem for writing debug log |
| 171 | + * |
| 172 | + * Makes the dirs |
| 173 | + */ |
| 174 | + async #prepareFs() { |
| 175 | + await B.all([fs.mkdirp(this.#logDirpath), fs.mkdirp(this.#lockfileDirpath)]); |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * Connects to a debug log and pipes its output somewhere. Returns a wrapper around the socket. |
| 180 | + * @returns {Promise<void>} - Telnet client |
| 181 | + */ |
| 182 | + async connect() { |
| 183 | + await this.#prepareFs(); |
| 184 | + |
| 185 | + this.#_unlock = await this.#lockLogfile(this.#hostSlug, this.logPath, this.#lockfileDirpath, { |
| 186 | + fd: this.#opts.logFd, |
| 187 | + }); |
| 188 | + const client = (this.#telnetClient = new Telnet()); |
| 189 | + await client.connect({ |
| 190 | + host: this.#host, |
| 191 | + port: this.#opts.port, |
| 192 | + encoding: 'utf8', |
| 193 | + ...this.#opts.telnetOpts, |
| 194 | + }); |
| 195 | + log.debug(`Connected to ${this.#host}`); |
| 196 | + |
| 197 | + client |
| 198 | + .on('error', (err) => { |
| 199 | + log.error(err); |
| 200 | + try { |
| 201 | + this.emit('error', err); |
| 202 | + } finally { |
| 203 | + this.#cleanup(); |
| 204 | + } |
| 205 | + }) |
| 206 | + .on('close', () => { |
| 207 | + log.info(`Connection to host ${this.#host} closed`); |
| 208 | + try { |
| 209 | + this.emit('close'); |
| 210 | + } finally { |
| 211 | + this.#cleanup(); |
| 212 | + } |
| 213 | + }) |
| 214 | + .on('timeout', () => { |
| 215 | + // inactivity timeout. 'close' will also be emitted (I think), so don't unlock here |
| 216 | + this.emit('timeout'); |
| 217 | + }); |
| 218 | + |
| 219 | + this.#pipeToLog(); |
| 220 | + } |
| 221 | + |
| 222 | + /** |
| 223 | + * Configure a pipe from the connected telnet client to the log file |
| 224 | + */ |
| 225 | + #pipeToLog = () => { |
| 226 | + const sock = this.#telnetClient?.getSocket(); |
| 227 | + if (sock) { |
| 228 | + // note: the encoding may just be `ascii` since that's what telnet is supposed to be |
| 229 | + sock |
| 230 | + .setEncoding('utf8') |
| 231 | + .on('finish', () => { |
| 232 | + this.emit('finish'); |
| 233 | + }) |
| 234 | + .on('open', () => { |
| 235 | + log.info(`Now writing to logfile ${this.logPath}`); |
| 236 | + }) |
| 237 | + .pipe(_fs.createWriteStream(this.logPath, {fd: this.#opts.logFd, encoding: 'utf8'})); |
| 238 | + |
| 239 | + } else { |
| 240 | + throw new ReferenceError('Not connected to host'); |
| 241 | + } |
| 242 | + }; |
| 243 | + |
| 244 | + /** |
| 245 | + * Resolves w/ the entire contents of the logfile |
| 246 | + */ |
| 247 | + async getLog() { |
| 248 | + return await fs.readFile(this.logPath, 'utf8'); |
| 249 | + } |
| 250 | + |
| 251 | + /** |
| 252 | + * Truncates the logfile. |
| 253 | + * |
| 254 | + * Do not use while connected to the host. |
| 255 | + */ |
| 256 | + async truncate() { |
| 257 | + try { |
| 258 | + await this.#cleanup?.(); |
| 259 | + } catch { |
| 260 | + throw new Error(`Could not unlock logfile at ${this.logPath}; disconnect first`); |
| 261 | + } |
| 262 | + await fs.writeFile(this.logPath, ''); |
| 263 | + log.info(`Truncated logfile at ${this.logPath}`); |
| 264 | + } |
| 265 | + |
| 266 | + /** |
| 267 | + * Read a single chunk from the log |
| 268 | + * @returns {Promise<string|undefined>} |
| 269 | + */ |
| 270 | + async nextData() { |
| 271 | + return await this.#telnetClient?.nextData(); |
| 272 | + } |
| 273 | + |
| 274 | + /** |
| 275 | + * Iterates over data coming out of Telnet host |
| 276 | + * @returns {AsyncGenerator<string>} |
| 277 | + */ |
| 278 | + async *[Symbol.asyncIterator]() { |
| 279 | + const sock = this.#telnetClient?.getSocket(); |
| 280 | + if (sock) { |
| 281 | + for await (const chunk of sock) { |
| 282 | + yield chunk; |
| 283 | + } |
| 284 | + } |
| 285 | + } |
| 286 | + |
| 287 | + /** |
| 288 | + * Disconnect from the host (if connected) |
| 289 | + */ |
| 290 | + async disconnect() { |
| 291 | + if (this.#telnetClient) { |
| 292 | + try { |
| 293 | + await this.#telnetClient.end(); |
| 294 | + } catch { |
| 295 | + await this.#telnetClient.destroy(); |
| 296 | + } finally { |
| 297 | + await this.#cleanup(); |
| 298 | + } |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + /** |
| 303 | + * Returns `true` if we're connected to the host |
| 304 | + */ |
| 305 | + get isConnected() { |
| 306 | + return Boolean(this.#telnetClient); |
| 307 | + } |
| 308 | +} |
| 309 | + |
| 310 | +/** |
| 311 | + * Options for {@linkcode RokuDebugLog} |
| 312 | + * @typedef RokuDebugLogOpts |
| 313 | + * @property {number} [port] - Debug port on Roku device; `8085` by default |
| 314 | + * @property {string} [logPath] - Path to destination |
| 315 | + * @property {_fs.promises.FileHandle | number} [logFd] - If provided {@linkcode ConnectDebugLogOpts.logPath} will be ignored |
| 316 | + * @property {import('telnet-client').ConnectOptions} [telnetOpts] - Options for {@linkcode Telnet} |
| 317 | + */ |
| 318 | + |
| 319 | +/** |
| 320 | + * Options for {@linkcode lockLogfile} |
| 321 | + * @internal |
| 322 | + * @private |
| 323 | + * @typedef LockLogfileOpts |
| 324 | + * @property {_fs.promises.FileHandle | number} [fd] |
| 325 | + */ |
0 commit comments