Skip to content

Commit 24d2aa3

Browse files
committed
feat(debug-log): create foundation of Roku debug log command
A Roku device has a debug log which is accessible via telnet. We can use this to provide data via the WD `/session/:sessionId/log` route. What's here so far is an implementation of a thing which just listens on an arbitrary telnet server and pipes its output to a file, since that's probably unwise to keep in memory. When the command is issued, the driver can send the contents of the file to the client (and probably truncate it?). This depends on type changes not yet published in Appium and will fail CI. Note: The reason for two separate Telnet libs is that one provides a server & client and another only provides a client, but the client in the former is broken and I couldn't find another decent module which creates a dummy telnet server for testing.
1 parent 9cd3989 commit 24d2aa3

File tree

6 files changed

+717
-28
lines changed

6 files changed

+717
-28
lines changed

lib/debug-log.js

+325
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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

Comments
 (0)