• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / roku-debug / 31208378856

07 Aug 2026 06:44PM UTC coverage: 73.212% (+0.3%) from 72.934%
31208378856

push

github

web-flow
Migrate to roku-deploy v4 and Roku Cloud Emulator support- #399 (#398)

Co-authored-by: Bronley Plumb <bronley@gmail.com>

3901 of 5576 branches covered (69.96%)

Branch coverage included in aggregate %.

173 of 195 new or added lines in 12 files covered. (88.72%)

3 existing lines in 2 files now uncovered.

6069 of 8042 relevant lines covered (75.47%)

49.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.55
/src/SceneGraphDebugCommandController.ts
1
import { createRokuDeploySocket } from 'roku-deploy';
2✔
2
import type { DeviceConfig, RokuDeploySocket, SocketOptions } from 'roku-deploy';
3
import { logger } from './logging';
2✔
4
// eslint-disable-next-line
5
const Telnet = require('telnet-client');
2✔
6

7
export class SceneGraphDebugCommandController {
2✔
8
    constructor(device: DeviceConfig, port?: number) {
9
        this.device = device;
57✔
10
        this.port = port ?? 8080;
57✔
11
    }
12

13
    /**
14
     * The roku-deploy device config for the target device
15
     */
16
    private device: DeviceConfig;
17

18
    private connection: typeof Telnet;
19

20
    private shellPrompt = /^>$/img;
57✔
21
    private echoLines = 0;
57✔
22
    public timeout = 5000;
57✔
23
    public execTimeout = 2000;
57✔
24
    private port;
25
    private maxBufferLength = 5242880;
57✔
26
    private logger = logger.createLogger(`[${SceneGraphDebugCommandController.name}]`);
57✔
27

28
    /**
29
     * Create the transport used to reach the SceneGraph debug server. Extracted to a protected
30
     * method so tests can substitute a fake socket.
31
     */
32
    protected createRokuDeploySocket(options: SocketOptions): RokuDeploySocket {
33
        return createRokuDeploySocket(options);
2✔
34
    }
35

36
    public async connect(options: { execTimeout?: number; timeout?: number } = {}) {
5✔
37
        this.removeConnection();
8✔
38

39
        const timeoutMs = options.timeout ?? this.timeout;
8✔
40

41
        let socket: RokuDeploySocket | undefined;
42
        try {
8✔
43
            socket = this.createRokuDeploySocket({
8✔
44
                device: this.device,
45
                port: this.port
46
            });
47
            //keep an error listener attached for the socket's whole life: waitForConnectPrompt and
48
            //telnet-client each remove or narrow theirs at various points, and a socket 'error'
49
            //emitted while no listener is attached would crash the whole process
50
            socket.on('error', (error: Error) => {
8✔
51
                this.logger.debug('SceneGraph debug server socket error', error);
2✔
52
            });
53

54
            await this.waitForSocketConnect(socket, timeoutMs);
8✔
55
            //an injected sock skips telnet-client's own prompt wait entirely (see the comment on
56
            //waitForConnectPrompt below), so the greeting has to be consumed here first or it would
57
            //otherwise arrive mid-exec and prematurely terminate the first command's response
58
            await this.waitForConnectPrompt(socket, timeoutMs);
5✔
59

60
            // Make a new telnet connections object
61
            let connection = new Telnet();
4✔
62

63
            connection.on('close', () => {
4✔
UNCOV
64
                this.removeConnection();
×
65
            });
66
            const config = {
4✔
67
                port: this.port,
68
                shellPrompt: this.shellPrompt,
69
                echoLines: this.echoLines,
70
                timeout: this.timeout,
71
                execTimeout: this.execTimeout,
72
                maxBufferLength: this.maxBufferLength,
73
                ...options,
74
                sock: socket
75
            };
76
            this.logger.debug('Establishing telnet connection', config);
4✔
77
            await connection.connect(config);
4✔
78
            this.connection = connection;
4✔
79
        } catch (e) {
80
            //the socket was created but never became the live connection, so nothing else will ever
81
            //destroy it. Leaving it dangling would leak an open socket or websocket.
82
            socket?.destroy();
4!
83
            throw new Error((e as Error).message);
4✔
84
        }
85
    }
86

87
    /**
88
     * Waits for the transport-level connection to establish (the tcp handshake for a local device,
89
     * or the websocket handshake for an RCE device), racing its 'connect' event against 'error' and
90
     * a manual timeout. An injected sock bypasses telnet-client's own connect timeout entirely
91
     * (telnet-client resolves immediately for an injected sock, before its timeout is even armed),
92
     * so this is the only thing enforcing one here.
93
     */
94
    private waitForSocketConnect(socket: RokuDeploySocket, timeoutMs: number): Promise<void> {
95
        return new Promise<void>((resolve, reject) => {
8✔
96
            const cleanup = () => {
8✔
97
                clearTimeout(timeoutHandle);
8✔
98
                socket.removeListener('connect', onConnect);
8✔
99
                socket.removeListener('error', onError);
8✔
100
            };
101
            const onConnect = () => {
8✔
102
                cleanup();
5✔
103
                resolve();
5✔
104
            };
105
            const onError = (error: Error) => {
8✔
106
                cleanup();
1✔
107
                reject(error);
1✔
108
            };
109
            const timeoutHandle = setTimeout(() => {
8✔
110
                cleanup();
2✔
111
                reject(new Error(`Timed out connecting to the SceneGraph debug server after ${timeoutMs}ms`));
2✔
112
            }, timeoutMs);
113

114
            socket.once('connect', onConnect);
8✔
115
            socket.once('error', onError);
8✔
116
            socket.connect();
8✔
117
        });
118
    }
119

120
    /**
121
     * Waits for the connection greeting (a bare `>` shell prompt) to arrive and consumes it before
122
     * the socket is handed to telnet-client. This mirrors the prompt wait telnet-client performs
123
     * when it owns the socket, which it skips entirely for an injected sock (it treats one as
124
     * already `'ready'`); without it the greeting would instead arrive mid-exec, where its prompt
125
     * would prematurely terminate the first command's response. Rejects if the prompt does not
126
     * arrive within `timeoutMs`.
127
     */
128
    private waitForConnectPrompt(socket: RokuDeploySocket, timeoutMs: number): Promise<void> {
129
        return new Promise<void>((resolve, reject) => {
5✔
130
            let accumulatedText = '';
5✔
131
            //a fresh non-global copy avoids a stateful lastIndex across calls, since
132
            //this.shellPrompt carries the 'g' flag
133
            const nonGlobalShellPrompt = new RegExp(this.shellPrompt.source, this.shellPrompt.flags.replace('g', ''));
5✔
134

135
            const finish = (error?: Error) => {
5✔
136
                socket.removeListener('data', onData);
5✔
137
                clearTimeout(timeoutHandle);
5✔
138
                if (error) {
5✔
139
                    reject(error);
1✔
140
                } else {
141
                    resolve();
4✔
142
                }
143
            };
144
            const onData = (chunk: Buffer) => {
5✔
145
                accumulatedText += chunk.toString('utf8');
5✔
146
                if (nonGlobalShellPrompt.test(accumulatedText)) {
5✔
147
                    finish();
4✔
148
                }
149
            };
150
            const timeoutHandle = setTimeout(() => {
5✔
151
                finish(new Error(`Timed out after ${timeoutMs}ms waiting for the SceneGraph debug server's shell prompt`));
1✔
152
            }, timeoutMs);
153

154
            socket.on('data', onData);
5✔
155
        });
156
    }
157

158
    private removeConnection() {
159
        this.connection = null;
8✔
160
    }
161

162
    /**
163
     * executes the different bsprof commands used for brightscript profiling.
164
     * @param {('pause'|'resume'|'status')} option Pause, resume, or get BS profiling status.
165
     */
166
    public async bsprof(option: 'pause' | 'resume' | 'status'): Promise<SceneGraphCommandResponse> {
167
        return this.exec(`bsprof-${option}`);
3✔
168
    }
169

170
    /**
171
     * Prints the current memory and CPU utilization of a channel (RAM usage is reported in KibiBytes [KiB]). The channel manifest must include the run_as_process=1 attribute to use this command.
172
     *
173
     * If an interval is provided the device repeats the command the specified number of seconds and outputs the results to port 8085.
174
     * (Available since Roku OS 10.0)
175
     * @param {{ interval: number }} [options] logging interval in seconds. 0 will stop interval logging.
176
     */
177
    public async chanperf(options?: { interval: number }): Promise<SceneGraphCommandResponse> {
178
        let command = 'chanperf';
4✔
179

180
        if (options) {
4✔
181
            // TODO: revisit this as channelId support is documented but the command does not seem to work. Device returns 'ERR: unknown arg: <channelId>'
182
            // command = options?.channelId ? `${command} ${options.channelId}` : command;
183
            command = options?.interval > -1 ? `${command} -r ${options.interval}` : command;
3!
184
        }
185

186
        return this.exec(command);
4✔
187
    }
188

189
    /**
190
     * Clear all caches that can affect channel launch time.
191
     */
192
    public async clearLaunchCaches(): Promise<SceneGraphCommandResponse> {
193
        return this.exec('clear_launch_caches');
1✔
194
    }
195

196
    /**
197
     * Displays frames-per-second and free memory on-screen. Leverage this tool to optimize your channel UI. It presents a 1-second moving average of the current frame rate.
198
     * @param {('off'|'on'|'toggle')} option
199
     */
200
    public async fpsDisplay(option: 'off' | 'on' | 'toggle'): Promise<SceneGraphCommandResponse> {
201
        let command = 'fps_display';
3✔
202

203
        if (option !== 'toggle') {
3✔
204
            command = `${command} ${option === 'on' ? 1 : 0}`;
2✔
205
        }
206

207
        let response = await this.exec(command);
3✔
208
        if (!response.error) {
3!
209
            response.result.data = `FPS Display: ${option}`;
3✔
210
        }
211
        return response;
3✔
212
    }
213

214
    /**
215
     * Provides a snapshot of the amount of in-use and free memory on the device.
216
     */
217
    public async free(): Promise<SceneGraphCommandResponse> {
218
        return this.exec('free');
1✔
219
    }
220

221
    /**
222
     * Generate a new developer key.
223
     */
224
    public async genkey(): Promise<SceneGraphCommandResponse> {
225
        return this.exec('genkey');
1✔
226
    }
227

228

229
    /**
230
     * Displays the current set of images loaded into texture memory.
231
     */
232
    public async loadedTextures(): Promise<SceneGraphCommandResponse> {
233
        return this.exec('loaded_textures');
1✔
234
    }
235

236
    /**
237
     * Enable, disable, or checks the status of console logging of thread rendezvous.
238
     * @param {('status'|'off'|'on')} option
239
     */
240
    public async logrendezvous(option: 'status' | 'off' | 'on'): Promise<SceneGraphCommandResponse> {
241
        let command = 'logrendezvous';
5✔
242

243
        if (option !== 'status') {
5✔
244
            command = `${command} ${option}`;
3✔
245
        }
246

247
        return this.exec(command);
5✔
248
    }
249

250
    /**
251
     * Show list of all installed plugins.
252
     */
253
    public async plugins(): Promise<SceneGraphCommandResponse> {
254
        return this.exec('plugins');
1✔
255
    }
256

257
    /**
258
     * Simulate a keypress.
259
     * @param {string[]} keys A list of keys to press in sequence
260
     */
261
    public async press(keys: string[]): Promise<SceneGraphCommandResponse> {
262
        // Add 1 second per character to the max execution timeout because roku is really slow......
263
        return this.exec(`press ${keys.join(', ')}`, { execTimeout: this.execTimeout + (keys.length * 1000) });
2✔
264
    }
265

266

267
    /**
268
     * Prints a list of assets loaded into texture memory and the amount of free, used, and maximum available memory on your device, respectively.
269
     * Starting with Roku OS 9.3, the name of each bitmap is included.
270
     */
271
    public async r2d2Bitmaps(): Promise<SceneGraphCommandResponse> {
272
        return this.exec('r2d2_bitmaps');
1✔
273
    }
274

275

276
    /**
277
     * Removes the indicated channel from the local device, as well as from all devices linked to the same Roku account. For example, if a channel has a channel id of "987654_cf9a", then the following command would remove it: remove_plugin 987654_cf9a
278
     *
279
     * The list of available channel ids can be seen with the 'plugins' command. The local device must be linked to a Roku account.
280
     *
281
     * To use this command, the local device must be linked to a Roku account. Channels are not removed on another device until it synchronizes with the Roku Channel Store (for example, via an automatic check for updates).
282
     * (Available since Roku OS 10.0)
283
     *
284
     * @param {string} channelId
285
     */
286
    public async removePlugin(channelId: string): Promise<SceneGraphCommandResponse> {
287
        return this.exec(`remove_plugin ${channelId}`);
2✔
288
    }
289

290

291
    /**
292
     * Prints every existing node created by the currently running channel.
293
     * As of Roku OS 10.0, this prints the number of 'osref' references to the node (held in the Roku platform) and 'bscref' references (held in the channel application).
294
     * The 'bcsref' count includes references from "m." variable and local variables. Child references and field references do not increase 'bscref' counts.
295
     *
296
     * The 'osref' count also includes child references and references from Roku SceneGraph interface fields. For example, for any node with a parent, the parent will count as one 'osref' on the child.
297
     * Additionally, any field of type 'node', 'nodearray', or 'assocarray' will add one 'osref' to each node referenced from within that field.
298
     * These could be in variables local to a function, arrays, or associative arrays, including a component global m or an associative array field of a node.
299
     *
300
     * The reported 'osref' count may vary from release to release of Roku OS; the information here is provided only to give a sense of the kinds of items that the count includes.
301
     * The 'bscref' count provides a more relevant and accurate indication of the resources that the channel itself controls.
302
     *
303
     * The sgnodes all, sgnodes roots, and sgnodes node_ID commands are similar to the getAll() , getRoots() , getRootsMeta(), and getAllMeta() ifSGNodeChildren methods, which can be called on any SceneGraph node.
304
     *
305
     * @param {string} id This can be 'all', 'roots', or the id of node(s) in your channel.
306
     */
307
    public async sgnodes(id: string): Promise<SceneGraphCommandResponse> {
308
        return this.exec(`sgnodes ${id}`);
3✔
309
    }
310

311

312
    /**
313
     * Provides basic node operation performance metrics. This command tracks all node operations by a thread, whether it's being created or an operation on an existing node, and whether it involves a rendezvous.
314
     * @param {('start'|'clear'|'report'|'stop')} action start - enables counting, clear - resets counters to zero, report - prints current counts with rendezvous as a percentage, stop - disables counting.
315
     */
316
    public async sgperf(action: 'start' | 'clear' | 'report' | 'stop'): Promise<SceneGraphCommandResponse> {
317
        return this.exec(`sgperf ${action}`);
4✔
318
    }
319

320
    /**
321
     * Show the current developer key
322
     */
323
    public async showkey(): Promise<SceneGraphCommandResponse> {
324
        return this.exec('showkey');
1✔
325
    }
326

327
    /**
328
     * Send a literal text sequence.
329
     * @param text string to be sent to the device.
330
     */
331
    public async type(text: string): Promise<SceneGraphCommandResponse> {
332
        // Add 1 second per character to the max execution timeout because roku is really slow......
333
        return this.exec(`type ${text}`, { execTimeout: this.execTimeout + (text.length * 1000) });
1✔
334
    }
335

336

337
    /**
338
     * Changes the number of brightscript warnings displayed on application install.
339
     * @param warningLimit maximum number of warnings to show
340
     */
341
    public async brightscriptWarnings(warningLimit: number): Promise<SceneGraphCommandResponse> {
342
        return this.exec(`brightscript_warnings ${warningLimit ?? 100}`);
×
343
    }
344

345

346
    /**
347
     * Send any custom command to the SceneGraph debug server.
348
     *
349
     * If this command is called and there is no active connection with the device we will attempt to connect.
350
     * In this case once the command has been executed we will then close the connection.
351
     * @param {string} command command to be run.
352
     */
353
    public async exec(command: string, options: { execTimeout?: number; timeout?: number } = {}): Promise<SceneGraphCommandResponse> {
3✔
354
        let response = this.getBlankResponseObject(command);
3✔
355
        this.logger.log(`Running SceneGraphDebugger command`, { command });
3✔
356

357
        // Set up a short lived connection if a long lived one has not beed started
358
        let closeConnectionAfterCommand = !this.connection;
3✔
359
        if (closeConnectionAfterCommand) {
3✔
360
            this.logger.trace('Opening new connection');
2✔
361
            try {
2✔
362
                await this.connect(options);
2✔
363
            } catch (error) {
364
                response.error = error;
2✔
365
            }
366
        }
367

368
        // Send the commend if we have a connection
369
        if (this.connection) {
3✔
370
            try {
1✔
371
                response.result.rawResponse = await this.connection.exec(command, options);
1✔
372
                this.logger.debug('Command complete', { command });
1✔
373
            } catch (error) {
374
                response.error = error;
×
375
            }
376
        }
377

378
        // Close the connection if we opened a short lived one
379
        if (closeConnectionAfterCommand) {
3✔
380
            this.logger.trace('Closing connection');
2✔
381
            await this.end();
2✔
382
        }
383

384
        // Tada! Results.
385
        return response;
3✔
386
    }
387

388

389
    /**
390
     * Closes the socket connection to the device
391
     */
392
    public async end() {
393
        if (this.connection) {
18!
394
            this.connection.removeListener('close', this.removeConnection);
×
395
            try {
×
396
                try {
×
397
                    // Asking the host to close is much faster then running our own connections destroy
398
                    await this.connection.exec('quit', { shellPrompt: 'Quit command received, exiting.' });
×
399
                } catch (error) {
400
                    this.logger.error(`There was a problem quitting the SceneGraphDebugCommand connection`, error);
×
401
                }
402
                this.removeConnection();
×
403
            } catch (error) {
404
                this.removeConnection();
×
405
                console.log(error, this.connection);
×
406
            }
407
        }
408
    }
409

410
    /**
411
     * Returns a simple starting object used for responses
412
     * @private
413
     */
414
    private getBlankResponseObject(command: string): SceneGraphCommandResponse {
415
        return {
3✔
416
            command: command,
417
            result: {
418
                rawResponse: ''
419
            }
420
        };
421
    }
422
}
423

424
export interface SceneGraphCommandResponse<T = undefined> {
425
    command: string;
426
    error?: SceneGraphCommandError<T>;
427
    result: {
428
        rawResponse: string;
429
        data?: any;
430
    };
431
}
432

433
interface SceneGraphCommandError<T = undefined> {
434
    message: string;
435
    type: 'socket' | 'device';
436
    data?: T;
437
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc