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

rokucommunity / roku-debug / #2019

pending completion
#2019

push

web-flow
Merge 15b99ed53 into 4961675eb

1897 of 2750 branches covered (68.98%)

Branch coverage included in aggregate %.

16 of 16 new or added lines in 3 files covered. (100.0%)

3447 of 4593 relevant lines covered (75.05%)

27.64 hits per line

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

59.11
/src/adapters/DebugProtocolAdapter.ts
1
import * as EventEmitter from 'events';
1✔
2
import { Socket } from 'net';
1✔
3
import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
4
import { CompileErrorProcessor } from '../CompileErrorProcessor';
1✔
5
import type { RendezvousHistory } from '../RendezvousTracker';
6
import { RendezvousTracker } from '../RendezvousTracker';
1✔
7
import type { ChanperfData } from '../ChanperfTracker';
8
import { ChanperfTracker } from '../ChanperfTracker';
1✔
9
import type { SourceLocation } from '../managers/LocationManager';
10
import { ErrorCode, PROTOCOL_ERROR_CODES } from '../debugProtocol/Constants';
1✔
11
import { defer, util } from '../util';
1✔
12
import { logger } from '../logging';
1✔
13
import * as semver from 'semver';
1✔
14
import type { AdapterOptions, HighLevelType, RokuAdapterEvaluateResponse } from '../interfaces';
15
import type { BreakpointManager } from '../managers/BreakpointManager';
16
import type { ProjectManager } from '../managers/ProjectManager';
17
import { ActionQueue } from '../managers/ActionQueue';
1✔
18
import type { BreakpointsVerifiedEvent, ConstructorOptions, ProtocolVersionDetails } from '../debugProtocol/client/DebugProtocolClient';
19
import { DebugProtocolClient } from '../debugProtocol/client/DebugProtocolClient';
1✔
20
import type { Variable } from '../debugProtocol/events/responses/VariablesResponse';
21
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
1✔
22
import type { TelnetAdapter } from './TelnetAdapter';
23

24
/**
25
 * A class that connects to a Roku device over telnet debugger port and provides a standardized way of interacting with it.
26
 */
27
export class DebugProtocolAdapter {
1✔
28
    constructor(
29
        private options: AdapterOptions & ConstructorOptions,
8✔
30
        private projectManager: ProjectManager,
8✔
31
        private breakpointManager: BreakpointManager
8✔
32
    ) {
33
        util.normalizeAdapterOptions(this.options);
8✔
34
        this.emitter = new EventEmitter();
8✔
35
        this.chanperfTracker = new ChanperfTracker();
8✔
36
        this.rendezvousTracker = new RendezvousTracker();
8✔
37
        this.compileErrorProcessor = new CompileErrorProcessor();
8✔
38
        this.connected = false;
8✔
39

40
        // watch for chanperf events
41
        this.chanperfTracker.on('chanperf', (output) => {
8✔
42
            this.emit('chanperf', output);
×
43
        });
44

45
        // watch for rendezvous events
46
        this.rendezvousTracker.on('rendezvous', (output) => {
8✔
47
            this.emit('rendezvous', output);
×
48
        });
49
    }
50

51
    private logger = logger.createLogger(`[${DebugProtocolAdapter.name}]`);
8✔
52

53
    /**
54
     * Indicates whether the adapter has successfully established a connection with the device
55
     */
56
    public connected: boolean;
57

58
    /**
59
     *  Due to casing issues with the variables request on some versions of the debug protocol, we first need to try the request in the supplied case.
60
     * If that fails we retry in lower case. This flag is used to drive that logic switching
61
     */
62
    private enableVariablesLowerCaseRetry = true;
8✔
63
    private supportsExecuteCommand: boolean;
64
    private compileClient: Socket;
65
    private compileErrorProcessor: CompileErrorProcessor;
66
    private emitter: EventEmitter;
67
    private chanperfTracker: ChanperfTracker;
68
    private rendezvousTracker: RendezvousTracker;
69
    private socketDebugger: DebugProtocolClient;
70
    private nextFrameId = 1;
8✔
71

72
    private stackFramesCache: Record<number, StackFrame> = {};
8✔
73
    private cache = {};
8✔
74

75
    /**
76
     * Get the version of the protocol for the Roku device we're currently connected to.
77
     */
78
    public get activeProtocolVersion() {
79
        return this.socketDebugger?.protocolVersion;
16!
80
    }
81

82
    public readonly supportsMultipleRuns = false;
8✔
83

84
    /**
85
     * Subscribe to an event exactly once
86
     * @param eventName
87
     */
88
    public once(eventName: 'cannot-continue'): Promise<void>;
89
    public once(eventname: 'chanperf'): Promise<ChanperfData>;
90
    public once(eventName: 'close'): Promise<void>;
91
    public once(eventName: 'app-exit'): Promise<void>;
92
    public once(eventName: 'diagnostics'): Promise<BSDebugDiagnostic>;
93
    public once(eventName: 'connected'): Promise<boolean>;
94
    public once(eventname: 'console-output'): Promise<string>; // TODO: might be able to remove this at some point
95
    public once(eventname: 'protocol-version'): Promise<ProtocolVersionDetails>;
96
    public once(eventname: 'rendezvous'): Promise<RendezvousHistory>;
97
    public once(eventName: 'runtime-error'): Promise<BrightScriptRuntimeError>;
98
    public once(eventName: 'suspend'): Promise<void>;
99
    public once(eventName: 'start'): Promise<void>;
100
    public once(eventname: 'unhandled-console-output'): Promise<string>;
101
    public once(eventName: string) {
102
        return new Promise((resolve) => {
8✔
103
            const disconnect = this.on(eventName as Parameters<DebugProtocolAdapter['on']>[0], (...args) => {
8✔
104
                disconnect();
8✔
105
                resolve(...args);
8✔
106
            });
107
        });
108
    }
109

110
    /**
111
     * Subscribe to various events
112
     * @param eventName
113
     * @param handler
114
     */
115
    public on(eventName: 'breakpoints-verified', handler: (event: BreakpointsVerifiedEvent) => void);
116
    public on(eventName: 'cannot-continue', handler: () => void);
117
    public on(eventname: 'chanperf', handler: (output: ChanperfData) => void);
118
    public on(eventName: 'close', handler: () => void);
119
    public on(eventName: 'app-exit', handler: () => void);
120
    public on(eventName: 'diagnostics', handler: (params: BSDebugDiagnostic[]) => void);
121
    public on(eventName: 'connected', handler: (params: boolean) => void);
122
    public on(eventname: 'console-output', handler: (output: string) => void); // TODO: might be able to remove this at some point.
123
    public on(eventname: 'protocol-version', handler: (output: ProtocolVersionDetails) => void);
124
    public on(eventname: 'rendezvous', handler: (output: RendezvousHistory) => void);
125
    public on(eventName: 'runtime-error', handler: (error: BrightScriptRuntimeError) => void);
126
    public on(eventName: 'suspend', handler: () => void);
127
    public on(eventName: 'start', handler: () => void);
128
    public on(eventname: 'unhandled-console-output', handler: (output: string) => void);
129
    public on(eventName: string, handler: (payload: any) => void) {
130
        this.emitter.on(eventName, handler);
8✔
131
        return () => {
8✔
132
            if (this.emitter !== undefined) {
8!
133
                this.emitter.removeListener(eventName, handler);
8✔
134
            }
135
        };
136
    }
137

138
    private emit(eventName: 'suspend');
139
    private emit(eventName: 'breakpoints-verified', event: BreakpointsVerifiedEvent);
140
    private emit(eventName: 'diagnostics', data: BSDebugDiagnostic[]);
141
    private emit(eventName: 'app-exit' | 'cannot-continue' | 'chanperf' | 'close' | 'connected' | 'console-output' | 'protocol-version' | 'rendezvous' | 'runtime-error' | 'start' | 'unhandled-console-output', data?);
142
    private emit(eventName: string, data?) {
143
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
144
        setTimeout(() => {
52✔
145
            //in rare cases, this event is fired after the debugger has closed, so make sure the event emitter still exists
146
            if (this.emitter) {
52!
147
                this.emitter.emit(eventName, data);
52✔
148
            }
149
        }, 0);
150
    }
151

152
    /**
153
     * The debugger needs to tell us when to be active (i.e. when the package was deployed)
154
     */
155
    public isActivated = false;
8✔
156

157
    /**
158
     * This will be set to true When the roku emits the [scrpt.ctx.run.enter] text,
159
     * which indicates that the app is running on the Roku
160
     */
161
    public isAppRunning = false;
8✔
162

163
    public activate() {
164
        this.isActivated = true;
×
165
        this.handleStartupIfReady();
×
166
    }
167

168
    public async sendErrors() {
169
        await this.compileErrorProcessor.sendErrors();
×
170
    }
171

172
    private handleStartupIfReady() {
173
        if (this.isActivated && this.isAppRunning) {
×
174
            this.emit('start');
×
175

176
            //if we are already sitting at a debugger prompt, we need to emit the first suspend event.
177
            //If not, then there are probably still messages being received, so let the normal handler
178
            //emit the suspend event when it's ready
179
            if (this.isAtDebuggerPrompt === true) {
×
180
                this.emit('suspend');
×
181
            }
182
        }
183
    }
184

185
    /**
186
     * Wait until the client has stopped sending messages. This is used mainly during .connect so we can ignore all old messages from the server
187
     * @param client
188
     * @param name
189
     * @param maxWaitMilliseconds
190
     */
191
    private settle(client: Socket, name: string, maxWaitMilliseconds = 400) {
×
192
        return new Promise((resolve) => {
×
193
            let callCount = -1;
×
194

195
            function handler() {
196
                callCount++;
×
197
                let myCallCount = callCount;
×
198
                setTimeout(() => {
×
199
                    //if no other calls have been made since the timeout started, then the listener has settled
200
                    if (myCallCount === callCount) {
×
201
                        client.removeListener(name, handler);
×
202
                        resolve(callCount);
×
203
                    }
204
                }, maxWaitMilliseconds);
205
            }
206

207
            client.addListener(name, handler);
×
208
            //call the handler immediately so we have a timeout
209
            handler();
×
210
        });
211
    }
212

213
    public get isAtDebuggerPrompt() {
214
        return this.socketDebugger?.isStopped ?? false;
26!
215
    }
216

217
    /**
218
     * Connect to the telnet session. This should be called before the channel is launched.
219
     */
220
    public async connect() {
221
        let deferred = defer();
8✔
222
        this.socketDebugger = new DebugProtocolClient(this.options);
8✔
223
        try {
8✔
224
            // Emit IO from the debugger.
225
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
226
            this.socketDebugger.on('io-output', async (responseText) => {
8✔
227
                if (typeof responseText === 'string') {
×
228
                    responseText = this.chanperfTracker.processLog(responseText);
×
229
                    responseText = await this.rendezvousTracker.processLog(responseText);
×
230
                    this.emit('unhandled-console-output', responseText);
×
231
                    this.emit('console-output', responseText);
×
232
                }
233
            });
234

235
            // Emit IO from the debugger.
236
            this.socketDebugger.on('protocol-version', (data: ProtocolVersionDetails) => {
8✔
237
                if (data.errorCode === PROTOCOL_ERROR_CODES.SUPPORTED) {
8!
238
                    this.emit('console-output', data.message);
×
239
                } else if (data.errorCode === PROTOCOL_ERROR_CODES.NOT_TESTED) {
8!
240
                    this.emit('unhandled-console-output', data.message);
8✔
241
                    this.emit('console-output', data.message);
8✔
242
                } else if (data.errorCode === PROTOCOL_ERROR_CODES.NOT_SUPPORTED) {
×
243
                    this.emit('unhandled-console-output', data.message);
×
244
                    this.emit('console-output', data.message);
×
245
                }
246

247
                // TODO: Update once we know the exact version of the debug protocol this issue was fixed in.
248
                // Due to casing issues with variables on protocol version <FUTURE_VERSION> and under we first need to try the request in the supplied case.
249
                // If that fails we retry in lower case.
250
                this.enableVariablesLowerCaseRetry = semver.satisfies(this.activeProtocolVersion, '<3.1.0');
8✔
251
                // While execute was added as a command in 2.1.0. It has shortcoming that prevented us for leveraging the command.
252
                // This was mostly addressed in the 3.0.0 release to the point where we were comfortable adding support for the command.
253
                this.supportsExecuteCommand = semver.satisfies(this.activeProtocolVersion, '>=3.0.0');
8✔
254
            });
255

256
            // Listen for the close event
257
            this.socketDebugger.on('close', () => {
8✔
258
                this.emit('close');
8✔
259
                this.beginAppExit();
8✔
260
            });
261

262
            // Listen for the app exit event
263
            this.socketDebugger.on('app-exit', () => {
8✔
264
                this.emit('app-exit');
×
265
            });
266

267
            this.socketDebugger.on('suspend', (data) => {
8✔
268
                this.clearCache();
8✔
269
                this.emit('suspend');
8✔
270
            });
271

272
            this.socketDebugger.on('runtime-error', (data) => {
8✔
273
                console.debug('hasRuntimeError!!', data);
×
274
                this.emit('runtime-error', <BrightScriptRuntimeError>{
×
275
                    message: data.data.stopReasonDetail,
276
                    errorCode: data.data.stopReason
277
                });
278
            });
279

280
            this.socketDebugger.on('cannot-continue', () => {
8✔
281
                this.emit('cannot-continue');
×
282
            });
283

284
            //handle when the device verifies breakpoints
285
            this.socketDebugger.on('breakpoints-verified', (event) => {
8✔
286
                let unverifiableDeviceIds = [] as number[];
4✔
287

288
                //mark the breakpoints as verified
289
                for (let breakpoint of event?.breakpoints ?? []) {
4!
290
                    const success = this.breakpointManager.verifyBreakpoint(breakpoint.id, true);
2✔
291
                    if (!success) {
2!
292
                        unverifiableDeviceIds.push(breakpoint.id);
2✔
293
                    }
294
                }
295
                //if there were any unsuccessful breakpoint verifications, we need to ask the device to delete those breakpoints as they've gone missing on our side
296
                if (unverifiableDeviceIds.length > 0) {
4✔
297
                    void this.socketDebugger.removeBreakpoints(unverifiableDeviceIds);
2✔
298
                }
299
                this.emit('breakpoints-verified', event);
4✔
300
            });
301

302
            this.connected = await this.socketDebugger.connect();
8✔
303

304
            this.logger.log(`Closing telnet connection used for compile errors`);
8✔
305
            if (this.compileClient) {
8!
306
                this.compileClient.removeAllListeners();
×
307
                this.compileClient.destroy();
×
308
                this.compileClient = undefined;
×
309
            }
310

311
            this.logger.log(`Connected to device`, { host: this.options.host, connected: this.connected });
8✔
312
            this.emit('connected', this.connected);
8✔
313

314
            //the adapter is connected and running smoothly. resolve the promise
315
            deferred.resolve();
8✔
316
        } catch (e) {
317
            deferred.reject(e);
×
318
        }
319
        return deferred.promise;
8✔
320
    }
321

322
    private beginAppExit() {
323
        this.compileErrorProcessor.compileErrorTimer = setTimeout(() => {
8✔
324
            this.isAppRunning = false;
8✔
325
            this.emit('app-exit');
8✔
326
        }, 200);
327
    }
328

329
    public async watchCompileOutput() {
330
        let deferred = defer();
×
331
        try {
×
332
            this.compileClient = new Socket();
×
333
            this.compileErrorProcessor.on('diagnostics', (errors) => {
×
334
                this.compileClient.end();
×
335
                this.emit('diagnostics', errors);
×
336
            });
337

338
            //if the connection fails, reject the connect promise
339
            this.compileClient.addListener('error', (err) => {
×
340
                deferred.reject(new Error(`Error with connection to: ${this.options.host}:${this.options.brightScriptConsolePort} \n\n ${err.message} `));
×
341
            });
342
            this.logger.info('Connecting via telnet to gather compile info', { host: this.options.host, port: this.options.brightScriptConsolePort });
×
343
            this.compileClient.connect(this.options.brightScriptConsolePort, this.options.host, () => {
×
344
                this.logger.log(`Connected via telnet to gather compile info`, { host: this.options.host, port: this.options.brightScriptConsolePort });
×
345
            });
346

347
            this.logger.debug('Waiting for the compile client to settle');
×
348
            await this.settle(this.compileClient, 'data');
×
349
            this.logger.debug('Compile client has settled');
×
350

351
            let lastPartialLine = '';
×
352
            this.compileClient.on('data', (buffer) => {
×
353
                let responseText = buffer.toString();
×
354
                this.logger.info('CompileClient received data', { responseText });
×
355
                if (!responseText.endsWith('\n')) {
×
356
                    this.logger.debug('Buffer was split');
×
357
                    // buffer was split, save the partial line
358
                    lastPartialLine += responseText;
×
359
                } else {
360
                    if (lastPartialLine) {
×
361
                        this.logger.debug('Previous response was split, so merging last response with this one', { lastPartialLine, responseText });
×
362
                        // there was leftover lines, join the partial lines back together
363
                        responseText = lastPartialLine + responseText;
×
364
                        lastPartialLine = '';
×
365
                    }
366
                    // Emit the completed io string.
367
                    this.compileErrorProcessor.processUnhandledLines(responseText.trim());
×
368
                    this.emit('unhandled-console-output', responseText.trim());
×
369
                }
370
            });
371

372
            // connected to telnet. resolve the promise
373
            deferred.resolve();
×
374
        } catch (e) {
375
            deferred.reject(e);
×
376
        }
377
        return deferred.promise;
×
378
    }
379

380
    /**
381
     * Send command to step over
382
     */
383
    public async stepOver(threadId: number) {
384
        this.clearCache();
×
385
        return this.socketDebugger.stepOver(threadId);
×
386
    }
387

388
    public async stepInto(threadId: number) {
389
        this.clearCache();
×
390
        return this.socketDebugger.stepIn(threadId);
×
391
    }
392

393
    public async stepOut(threadId: number) {
394
        this.clearCache();
×
395
        return this.socketDebugger.stepOut(threadId);
×
396
    }
397

398
    /**
399
     * Tell the brightscript program to continue (i.e. resume program)
400
     */
401
    public async continue() {
402
        this.clearCache();
×
403
        return this.socketDebugger.continue();
×
404
    }
405

406
    /**
407
     * Tell the brightscript program to pause (fall into debug mode)
408
     */
409
    public async pause() {
410
        this.clearCache();
×
411
        //send the kill signal, which breaks into debugger mode
412
        return this.socketDebugger.pause();
×
413
    }
414

415
    /**
416
     * Clears the state, which means that everything will be retrieved fresh next time it is requested
417
     */
418
    public clearCache() {
419
        this.cache = {};
8✔
420
        this.stackFramesCache = {};
8✔
421
    }
422

423
    /**
424
     * Execute a command directly on the roku. Returns the output of the command
425
     * @param command
426
     * @returns the output of the command (if possible)
427
     */
428
    public async evaluate(command: string, frameId: number = this.socketDebugger.primaryThread): Promise<RokuAdapterEvaluateResponse> {
×
429
        if (this.supportsExecuteCommand) {
×
430
            if (!this.isAtDebuggerPrompt) {
×
431
                throw new Error('Cannot run evaluate: debugger is not paused');
×
432
            }
433

434
            let stackFrame = this.getStackFrameById(frameId);
×
435
            if (!stackFrame) {
×
436
                throw new Error('Cannot execute command without a corresponding frame');
×
437
            }
438
            this.logger.log('evaluate ', { command, frameId });
×
439

440
            const response = await this.socketDebugger.executeCommand(command, stackFrame.frameIndex, stackFrame.threadIndex);
×
441
            this.logger.info('evaluate response', { command, response });
×
442
            if (response.data.executeSuccess) {
×
443
                return {
×
444
                    message: undefined,
445
                    type: 'message'
446
                };
447
            } else {
448
                const messages = [
×
449
                    ...response?.data?.compileErrors ?? [],
×
450
                    ...response?.data?.runtimeErrors ?? [],
×
451
                    ...response?.data?.otherErrors ?? []
×
452
                ];
453
                return {
×
454
                    message: messages[0] ?? 'Unknown error executing command',
×
455
                    type: 'error'
456
                };
457
            }
458
        } else {
459
            return {
×
460
                message: `Execute commands are not supported on debug protocol: ${this.activeProtocolVersion}, v3.0.0 or greater is required.`,
461
                type: 'error'
462
            };
463
        }
464
    }
465

466
    public async getStackTrace(threadIndex: number = this.socketDebugger.primaryThread) {
×
467
        if (!this.isAtDebuggerPrompt) {
10!
468
            throw new Error('Cannot get stack trace: debugger is not paused');
×
469
        }
470
        return this.resolve(`stack trace for thread ${threadIndex}`, async () => {
10✔
471
            let thread = await this.getThreadByThreadId(threadIndex);
9✔
472
            let frames: StackFrame[] = [];
9✔
473
            let stackTraceData = await this.socketDebugger.getStackTrace(threadIndex);
9✔
474
            for (let i = 0; i < stackTraceData?.data?.entries?.length ?? 0; i++) {
9!
475
                let frameData = stackTraceData.data.entries[i];
8✔
476
                let stackFrame: StackFrame = {
8✔
477
                    frameId: this.nextFrameId++,
478
                    // frame index is the reverse of the returned order.
479
                    frameIndex: stackTraceData.data.entries.length - i - 1,
480
                    threadIndex: threadIndex,
481
                    filePath: frameData.filePath,
482
                    lineNumber: frameData.lineNumber,
483
                    // eslint-disable-next-line no-nested-ternary
484
                    functionIdentifier: this.cleanUpFunctionName(i === 0 ? (frameData.functionName) ? frameData.functionName : thread.functionName : frameData.functionName)
16!
485
                };
486
                this.stackFramesCache[stackFrame.frameId] = stackFrame;
8✔
487
                frames.push(stackFrame);
8✔
488
            }
489
            //if the first frame is missing any data, suppliment with thread information
490
            if (frames[0]) {
9✔
491
                frames[0].filePath ??= thread.filePath;
8!
492
                frames[0].lineNumber ??= thread.lineNumber;
8!
493
            }
494

495
            return frames;
9✔
496
        });
497
    }
498

499
    private getStackFrameById(frameId: number): StackFrame {
500
        return this.stackFramesCache[frameId];
2✔
501
    }
502

503
    private cleanUpFunctionName(functionName): string {
504
        return functionName.substring(functionName.lastIndexOf('@') + 1);
8✔
505
    }
506

507
    /**
508
     * Get info about the specified variable.
509
     * @param expression the expression for the specified variable (i.e. `m`, `someVar.value`, `arr[1][2].three`). If empty string/undefined is specified, all local variables are retrieved instead
510
     */
511
    private async getVariablesResponse(expression: string, frameId: number) {
512
        const isScopesRequest = expression === '';
2✔
513
        const logger = this.logger.createLogger(' getVariable');
2✔
514
        logger.info('begin', { expression });
2✔
515
        if (!this.isAtDebuggerPrompt) {
2!
516
            throw new Error('Cannot resolve variable: debugger is not paused');
×
517
        }
518

519
        let frame = this.getStackFrameById(frameId);
2✔
520
        if (!frame) {
2!
521
            throw new Error('Cannot request variable without a corresponding frame');
×
522
        }
523

524
        logger.log(`Expression:`, expression);
2✔
525
        let variablePath = expression === '' ? [] : util.getVariablePath(expression);
2✔
526

527
        // Temporary workaround related to casing issues over the protocol
528
        if (this.enableVariablesLowerCaseRetry && variablePath?.length > 0) {
2!
529
            variablePath[0] = variablePath[0].toLowerCase();
×
530
        }
531

532
        let response = await this.socketDebugger.getVariables(variablePath, frame.frameIndex, frame.threadIndex);
2✔
533

534
        if (this.enableVariablesLowerCaseRetry && response.data.errorCode !== ErrorCode.OK) {
2!
535
            // Temporary workaround related to casing issues over the protocol
536
            logger.log(`Retrying expression as lower case:`, expression);
×
537
            variablePath = expression === '' ? [] : util.getVariablePath(expression?.toLowerCase());
×
538
            response = await this.socketDebugger.getVariables(variablePath, frame.frameIndex, frame.threadIndex);
×
539
        }
540
        return response;
2✔
541
    }
542

543
    /**
544
     * Get the variable for the specified expression.
545
     */
546
    public async getVariable(expression: string, frameId: number) {
547
        const response = await this.getVariablesResponse(expression, frameId);
1✔
548

549
        if (Array.isArray(response?.data?.variables)) {
1!
550
            const container = this.createEvaluateContainer(
1✔
551
                response.data.variables[0],
552
                //the name of the top container is the expression itself
553
                expression,
554
                //this is the top-level container, so there are no parent keys to this entry
555
                undefined
556
            );
557
            return container;
1✔
558
        }
559
    }
560

561
    /**
562
     * Get the list of local variables
563
     */
564
    public async getLocalVariables(frameId: number) {
565
        const response = await this.getVariablesResponse('', frameId);
1✔
566

567
        if (response?.data?.errorCode === ErrorCode.OK && Array.isArray(response?.data?.variables)) {
1!
568
            //create a top-level container to hold all the local vars
569
            const container = this.createEvaluateContainer(
1✔
570
                //dummy data
571
                {
572
                    isConst: false,
573
                    isContainer: true,
574
                    keyType: VariableType.String,
575
                    refCount: undefined,
576
                    type: VariableType.AssociativeArray,
577
                    value: undefined,
578
                    children: response.data.variables
579
                },
580
                //no name, this is a dummy container
581
                undefined,
582
                //there's no parent path
583
                undefined
584
            );
585
            return container;
1✔
586
        }
587
    }
588

589
    /**
590
     * Create an EvaluateContainer for the given variable. If the variable has children, those are created and attached as well
591
     * @param variable a Variable object from the debug protocol debugger
592
     * @param name the name of this variable. For example, `alpha.beta.charlie`, this value would be `charlie`. For local vars, this is the root variable name (i.e. `alpha`)
593
     * @param parentEvaluateName the string used to derive the parent, _excluding_ this variable's name (i.e. `alpha.beta` or `alpha[0]`)
594
     */
595
    private createEvaluateContainer(variable: Variable, name: string, parentEvaluateName: string) {
596
        let value;
597
        let variableType = variable.type;
6✔
598
        if (variable.value === null) {
6!
599
            value = 'roInvalid';
×
600
        } else if (variableType === 'String') {
6✔
601
            value = `\"${variable.value}\"`;
2✔
602
        } else {
603
            value = variable.value;
4✔
604
        }
605

606
        if (variableType === VariableType.SubtypedObject) {
6!
607
            //subtyped objects can only have string values
608
            let parts = (variable.value as string).split('; ');
×
609
            (variableType as string) = `${parts[0]} (${parts[1]})`;
×
610
        } else if (variableType === VariableType.AssociativeArray) {
6✔
611
            variableType = VariableType.AssociativeArray;
3✔
612
        }
613

614
        //build full evaluate name for this var. (i.e. `alpha["beta"]` + ["charlie"]` === `alpha["beta"]["charlie"]`)
615
        let evaluateName: string;
616
        if (!parentEvaluateName?.trim()) {
6✔
617
            evaluateName = name;
4✔
618
        } else if (typeof name === 'string') {
2!
619
            evaluateName = `${parentEvaluateName}["${name}"]`;
2✔
620
        } else if (typeof name === 'number') {
×
621
            evaluateName = `${parentEvaluateName}[${name}]`;
×
622
        }
623

624
        let container: EvaluateContainer = {
6✔
625
            name: name ?? '',
18✔
626
            evaluateName: evaluateName ?? '',
18✔
627
            type: variableType ?? '',
18!
628
            value: value ?? null,
18✔
629
            highLevelType: undefined,
630
            //non object/array variables don't have a key type
631
            keyType: variable.keyType as unknown as KeyType,
632
            elementCount: variable.childCount ?? variable.children?.length ?? undefined,
51✔
633
            //non object/array variables still need to have an empty `children` array to help upstream logic. The `keyType` being null is how we know it doesn't actually have children
634
            children: []
635
        };
636

637
        //recursively generate children containers
638
        if ([KeyType.integer, KeyType.string].includes(container.keyType) && Array.isArray(variable.children)) {
6✔
639
            container.children = [];
2✔
640
            for (let i = 0; i < variable.children.length; i++) {
2✔
641
                const childVariable = variable.children[i];
4✔
642
                const childContainer = this.createEvaluateContainer(
4✔
643
                    childVariable,
644
                    container.keyType === KeyType.integer ? i.toString() : childVariable.name,
4!
645
                    container.evaluateName
646
                );
647
                container.children.push(childContainer);
4✔
648
            }
649
        }
650
        return container;
6✔
651
    }
652

653
    /**
654
     * Cache items by a unique key
655
     * @param expression
656
     * @param factory
657
     */
658
    private resolve<T>(key: string, factory: () => T | Thenable<T>): Promise<T> {
659
        if (this.cache[key]) {
19✔
660
            this.logger.log('return cashed response', key, this.cache[key]);
2✔
661
            return this.cache[key];
2✔
662
        }
663
        this.cache[key] = Promise.resolve<T>(factory());
17✔
664
        return this.cache[key];
17✔
665
    }
666

667
    /**
668
     * Get a list of threads. The active thread will always be first in the list.
669
     */
670
    public async getThreads() {
671
        if (!this.isAtDebuggerPrompt) {
9!
672
            throw new Error('Cannot get threads: debugger is not paused');
×
673
        }
674
        return this.resolve('threads', async () => {
9✔
675
            let threads: Thread[] = [];
8✔
676
            let threadsResponse = await this.socketDebugger.threads();
8✔
677

678
            for (let i = 0; i < threadsResponse.data.threads.length; i++) {
8✔
679
                let threadInfo = threadsResponse.data.threads[i];
8✔
680
                let thread = <Thread>{
8✔
681
                    // NOTE: On THREAD_ATTACHED events the threads request is marking the wrong thread as primary.
682
                    // NOTE: Rely on the thead index from the threads update event.
683
                    isSelected: this.socketDebugger.primaryThread === i,
684
                    // isSelected: threadInfo.isPrimary,
685
                    filePath: threadInfo.filePath,
686
                    functionName: threadInfo.functionName,
687
                    lineNumber: threadInfo.lineNumber + 1, //protocol is 1-based
688
                    lineContents: threadInfo.codeSnippet,
689
                    threadId: i
690
                };
691
                threads.push(thread);
8✔
692
            }
693
            //make sure the selected thread is at the top
694
            threads.sort((a, b) => {
8✔
695
                return a.isSelected ? -1 : 1;
×
696
            });
697

698
            return threads;
8✔
699
        });
700
    }
701

702
    private async getThreadByThreadId(threadId: number) {
703
        let threads = await this.getThreads();
9✔
704
        for (let thread of threads) {
9✔
705
            if (thread.threadId === threadId) {
9✔
706
                return thread;
8✔
707
            }
708
        }
709
    }
710

711
    public removeAllListeners() {
712
        this.emitter?.removeAllListeners();
×
713
    }
714

715
    /**
716
     * Disconnect from the telnet session and unset all objects
717
     */
718
    public async destroy() {
719
        if (this.socketDebugger) {
×
720
            // destroy might be called due to a compile error so the socket debugger might not exist yet
721
            await this.socketDebugger.exitChannel();
×
722
        }
723

724
        this.cache = undefined;
×
725
        if (this.emitter) {
×
726
            this.emitter.removeAllListeners();
×
727
        }
728
        this.emitter = undefined;
×
729
    }
730

731
    // #region Rendezvous Tracker pass though functions
732
    /**
733
     * Passes the debug functions used to locate the client files and lines to the RendezvousTracker
734
     */
735
    public registerSourceLocator(sourceLocator: (debuggerPath: string, lineNumber: number) => Promise<SourceLocation>) {
736
        this.rendezvousTracker.registerSourceLocator(sourceLocator);
×
737
    }
738

739
    /**
740
     * Passes the log level down to the RendezvousTracker and ChanperfTracker
741
     * @param outputLevel the consoleOutput from the launch config
742
     */
743
    public setConsoleOutput(outputLevel: string) {
744
        this.chanperfTracker.setConsoleOutput(outputLevel);
×
745
        this.rendezvousTracker.setConsoleOutput(outputLevel);
×
746
    }
747

748
    /**
749
     * Sends a call to the RendezvousTracker to clear the current rendezvous history
750
     */
751
    public clearRendezvousHistory() {
752
        this.rendezvousTracker.clearHistory();
×
753
    }
754

755
    /**
756
     * Sends a call to the ChanperfTracker to clear the current chanperf history
757
     */
758
    public clearChanperfHistory() {
759
        this.chanperfTracker.clearHistory();
×
760
    }
761
    // #endregion
762

763
    public async syncBreakpoints() {
764
        //we can't send breakpoints unless we're stopped (or in a protocol version that supports sending them while running).
765
        //So...if we're not stopped, quit now. (we'll get called again when the stop event happens)
766
        if (!this.socketDebugger?.supportsBreakpointRegistrationWhileRunning && !this.isAtDebuggerPrompt) {
7!
767
            return;
×
768
        }
769

770
        //compute breakpoint changes since last sync
771
        const diff = await this.breakpointManager.getDiff(this.projectManager.getAllProjects());
7✔
772

773
        //delete these breakpoints
774
        if (diff.removed.length > 0) {
7✔
775
            await this.actionQueue.run(async () => {
2✔
776
                const response = await this.socketDebugger.removeBreakpoints(
2✔
777
                    //TODO handle retrying to remove unverified breakpoints that might get verified in the future AFTER we've removed them (that's hard...)
778
                    diff.removed.map(x => x.deviceId).filter(x => typeof x === 'number')
2✔
779
                );
780
                //return true to mark this action as complete, or false to retry the task again in the future
781
                return response.success && response.data.errorCode === ErrorCode.OK;
2✔
782
            }, 10);
783
        }
784

785
        if (diff.added.length > 0) {
7✔
786
            const breakpointsToSendToDevice = diff.added.map(breakpoint => {
4✔
787
                const hitCount = parseInt(breakpoint.hitCondition);
4✔
788
                return {
4✔
789
                    filePath: breakpoint.pkgPath,
790
                    lineNumber: breakpoint.line,
791
                    hitCount: !isNaN(hitCount) ? hitCount : undefined,
4!
792
                    conditionalExpression: breakpoint.condition,
793
                    key: breakpoint.hash,
794
                    componentLibraryName: breakpoint.componentLibraryName
795
                };
796
            });
797

798
            //send these new breakpoints to the device
799
            await this.actionQueue.run(async () => {
4✔
800
                //split the list into conditional and non-conditional breakpoints.
801
                //(TODO we can eliminate this splitting logic once the conditional breakpoints "continue" bug in protocol is fixed)
802
                const standardBreakpoints: typeof breakpointsToSendToDevice = [];
4✔
803
                const conditionalBreakpoints: typeof breakpointsToSendToDevice = [];
4✔
804
                for (const breakpoint of breakpointsToSendToDevice) {
4✔
805
                    if (breakpoint?.conditionalExpression?.trim()) {
4!
806
                        conditionalBreakpoints.push(breakpoint);
1✔
807
                    } else {
808
                        standardBreakpoints.push(breakpoint);
3✔
809
                    }
810
                }
811
                let success = true;
4✔
812
                for (const breakpoints of [standardBreakpoints, conditionalBreakpoints]) {
4✔
813
                    const response = await this.socketDebugger.addBreakpoints(breakpoints);
8✔
814
                    if (response.data.errorCode === ErrorCode.OK) {
8!
815
                        for (let i = 0; i < response?.data?.breakpoints?.length ?? 0; i++) {
8!
816
                            const deviceBreakpoint = response.data.breakpoints[i];
2✔
817
                            //sync this breakpoint's deviceId with the roku-assigned breakpoint ID
818
                            this.breakpointManager.setBreakpointDeviceId(
2✔
819
                                breakpoints[i].key,
820
                                deviceBreakpoint.id
821
                            );
822
                        }
823
                        //return true to mark this action as complete
824
                        success &&= true;
8✔
825
                    } else {
826
                        //this action is not yet complete. it should be retried
827
                        success &&= false;
×
828
                    }
829
                }
830
                return success;
4✔
831
            });
832
        }
833
    }
834

835
    private actionQueue = new ActionQueue();
8✔
836
}
837

838
export interface StackFrame {
839
    frameId: number;
840
    frameIndex: number;
841
    threadIndex: number;
842
    filePath: string;
843
    lineNumber: number;
844
    functionIdentifier: string;
845
}
846

847
export enum EventName {
1✔
848
    suspend = 'suspend'
1✔
849
}
850

851
export interface EvaluateContainer {
852
    name: string;
853
    evaluateName: string;
854
    type: string;
855
    value: string;
856
    keyType?: KeyType;
857
    elementCount: number;
858
    highLevelType: HighLevelType;
859
    children: EvaluateContainer[];
860
    presentationHint?: 'property' | 'method' | 'class' | 'data' | 'event' | 'baseClass' | 'innerClass' | 'interface' | 'mostDerivedClass' | 'virtual' | 'dataBreakpoint';
861
}
862

863
export enum KeyType {
1✔
864
    string = 'String',
1✔
865
    integer = 'Integer',
1✔
866
    legacy = 'Legacy'
1✔
867
}
868

869
export interface Thread {
870
    isSelected: boolean;
871
    lineNumber: number;
872
    filePath: string;
873
    functionName: string;
874
    lineContents: string;
875
    threadId: number;
876
}
877

878
interface BrightScriptRuntimeError {
879
    message: string;
880
    errorCode: string;
881
}
882

883
export function isDebugProtocolAdapter(adapter: TelnetAdapter | DebugProtocolAdapter): adapter is DebugProtocolAdapter {
1✔
884
    return adapter?.constructor.name === DebugProtocolAdapter.name;
1!
885
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc