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

rokucommunity / roku-debug / #2012

pending completion
#2012

push

web-flow
Merge 5cc22ee11 into f30a7deaa

1850 of 2724 branches covered (67.91%)

Branch coverage included in aggregate %.

1866 of 1866 new or added lines in 41 files covered. (100.0%)

3398 of 4571 relevant lines covered (74.34%)

25.86 hits per line

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

56.28
/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,
6✔
30
        private projectManager: ProjectManager,
6✔
31
        private breakpointManager: BreakpointManager
6✔
32
    ) {
33
        util.normalizeAdapterOptions(this.options);
6✔
34
        this.emitter = new EventEmitter();
6✔
35
        this.chanperfTracker = new ChanperfTracker();
6✔
36
        this.rendezvousTracker = new RendezvousTracker();
6✔
37
        this.compileErrorProcessor = new CompileErrorProcessor();
6✔
38
        this.connected = false;
6✔
39

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

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

51
    private logger = logger.createLogger(`[${DebugProtocolAdapter.name}]`);
6✔
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;
6✔
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;
6✔
71

72
    private stackFramesCache: Record<number, StackFrame> = {};
6✔
73
    private cache = {};
6✔
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;
12!
80
    }
81

82
    public readonly supportsMultipleRuns = false;
6✔
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) => {
6✔
103
            const disconnect = this.on(eventName as Parameters<DebugProtocolAdapter['on']>[0], (...args) => {
6✔
104
                disconnect();
6✔
105
                resolve(...args);
6✔
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);
6✔
131
        return () => {
6✔
132
            if (this.emitter !== undefined) {
6!
133
                this.emitter.removeListener(eventName, handler);
6✔
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(() => {
32✔
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) {
32!
147
                this.emitter.emit(eventName, data);
32✔
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;
6✔
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;
6✔
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;
20!
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();
6✔
222
        this.socketDebugger = new DebugProtocolClient(this.options);
6✔
223
        try {
6✔
224
            // Emit IO from the debugger.
225
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
226
            this.socketDebugger.on('io-output', async (responseText) => {
6✔
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) => {
6✔
237
                if (data.errorCode === PROTOCOL_ERROR_CODES.SUPPORTED) {
6!
238
                    this.emit('console-output', data.message);
×
239
                } else if (data.errorCode === PROTOCOL_ERROR_CODES.NOT_TESTED) {
6!
240
                    this.emit('unhandled-console-output', data.message);
6✔
241
                    this.emit('console-output', data.message);
6✔
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');
6✔
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');
6✔
254
            });
255

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

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

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

272
            this.socketDebugger.on('runtime-error', (data) => {
6✔
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', () => {
6✔
281
                this.emit('cannot-continue');
×
282
            });
283

284
            //handle when the device verifies breakpoints
285
            this.socketDebugger.on('breakpoints-verified', (event) => {
6✔
286
                //mark the breakpoints as verified
287
                for (let breakpoint of event?.breakpoints ?? []) {
2!
288
                    this.breakpointManager.verifyBreakpoint(breakpoint.id, true);
×
289
                }
290
                this.emit('breakpoints-verified', event);
2✔
291
            });
292

293
            this.connected = await this.socketDebugger.connect();
6✔
294

295
            this.logger.log(`Closing telnet connection used for compile errors`);
6✔
296
            if (this.compileClient) {
6!
297
                this.compileClient.removeAllListeners();
×
298
                this.compileClient.destroy();
×
299
                this.compileClient = undefined;
×
300
            }
301

302
            this.logger.log(`Connected to device`, { host: this.options.host, connected: this.connected });
6✔
303
            this.emit('connected', this.connected);
6✔
304

305
            //the adapter is connected and running smoothly. resolve the promise
306
            deferred.resolve();
6✔
307
        } catch (e) {
308
            deferred.reject(e);
×
309
        }
310
        return deferred.promise;
6✔
311
    }
312

313
    private beginAppExit() {
314
        this.compileErrorProcessor.compileErrorTimer = setTimeout(() => {
×
315
            this.isAppRunning = false;
×
316
            this.emit('app-exit');
×
317
        }, 200);
318
    }
319

320
    public async watchCompileOutput() {
321
        let deferred = defer();
×
322
        try {
×
323
            this.compileClient = new Socket();
×
324
            this.compileErrorProcessor.on('diagnostics', (errors) => {
×
325
                this.compileClient.end();
×
326
                this.emit('diagnostics', errors);
×
327
            });
328

329
            //if the connection fails, reject the connect promise
330
            this.compileClient.addListener('error', (err) => {
×
331
                deferred.reject(new Error(`Error with connection to: ${this.options.host}:${this.options.brightScriptConsolePort} \n\n ${err.message} `));
×
332
            });
333
            this.logger.info('Connecting via telnet to gather compile info', { host: this.options.host, port: this.options.brightScriptConsolePort });
×
334
            this.compileClient.connect(this.options.brightScriptConsolePort, this.options.host, () => {
×
335
                this.logger.log(`Connected via telnet to gather compile info`, { host: this.options.host, port: this.options.brightScriptConsolePort });
×
336
            });
337

338
            this.logger.debug('Waiting for the compile client to settle');
×
339
            await this.settle(this.compileClient, 'data');
×
340
            this.logger.debug('Compile client has settled');
×
341

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

363
            // connected to telnet. resolve the promise
364
            deferred.resolve();
×
365
        } catch (e) {
366
            deferred.reject(e);
×
367
        }
368
        return deferred.promise;
×
369
    }
370

371
    /**
372
     * Send command to step over
373
     */
374
    public async stepOver(threadId: number) {
375
        this.clearCache();
×
376
        return this.socketDebugger.stepOver(threadId);
×
377
    }
378

379
    public async stepInto(threadId: number) {
380
        this.clearCache();
×
381
        return this.socketDebugger.stepIn(threadId);
×
382
    }
383

384
    public async stepOut(threadId: number) {
385
        this.clearCache();
×
386
        return this.socketDebugger.stepOut(threadId);
×
387
    }
388

389
    /**
390
     * Tell the brightscript program to continue (i.e. resume program)
391
     */
392
    public async continue() {
393
        this.clearCache();
×
394
        return this.socketDebugger.continue();
×
395
    }
396

397
    /**
398
     * Tell the brightscript program to pause (fall into debug mode)
399
     */
400
    public async pause() {
401
        this.clearCache();
×
402
        //send the kill signal, which breaks into debugger mode
403
        return this.socketDebugger.pause();
×
404
    }
405

406
    /**
407
     * Clears the state, which means that everything will be retrieved fresh next time it is requested
408
     */
409
    public clearCache() {
410
        this.cache = {};
6✔
411
        this.stackFramesCache = {};
6✔
412
    }
413

414
    /**
415
     * Execute a command directly on the roku. Returns the output of the command
416
     * @param command
417
     * @returns the output of the command (if possible)
418
     */
419
    public async evaluate(command: string, frameId: number = this.socketDebugger.primaryThread): Promise<RokuAdapterEvaluateResponse> {
×
420
        if (this.supportsExecuteCommand) {
×
421
            if (!this.isAtDebuggerPrompt) {
×
422
                throw new Error('Cannot run evaluate: debugger is not paused');
×
423
            }
424

425
            let stackFrame = this.getStackFrameById(frameId);
×
426
            if (!stackFrame) {
×
427
                throw new Error('Cannot execute command without a corresponding frame');
×
428
            }
429
            this.logger.log('evaluate ', { command, frameId });
×
430

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

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

486
            return frames;
7✔
487
        });
488
    }
489

490
    private getStackFrameById(frameId: number): StackFrame {
491
        return this.stackFramesCache[frameId];
2✔
492
    }
493

494
    private cleanUpFunctionName(functionName): string {
495
        return functionName.substring(functionName.lastIndexOf('@') + 1);
6✔
496
    }
497

498
    /**
499
     * Get info about the specified variable.
500
     * @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
501
     */
502
    private async getVariablesResponse(expression: string, frameId: number) {
503
        const isScopesRequest = expression === '';
2✔
504
        const logger = this.logger.createLogger(' getVariable');
2✔
505
        logger.info('begin', { expression });
2✔
506
        if (!this.isAtDebuggerPrompt) {
2!
507
            throw new Error('Cannot resolve variable: debugger is not paused');
×
508
        }
509

510
        let frame = this.getStackFrameById(frameId);
2✔
511
        if (!frame) {
2!
512
            throw new Error('Cannot request variable without a corresponding frame');
×
513
        }
514

515
        logger.log(`Expression:`, expression);
2✔
516
        let variablePath = expression === '' ? [] : util.getVariablePath(expression);
2✔
517

518
        // Temporary workaround related to casing issues over the protocol
519
        if (this.enableVariablesLowerCaseRetry && variablePath?.length > 0) {
2!
520
            variablePath[0] = variablePath[0].toLowerCase();
×
521
        }
522

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

525
        if (this.enableVariablesLowerCaseRetry && response.data.errorCode !== ErrorCode.OK) {
2!
526
            // Temporary workaround related to casing issues over the protocol
527
            logger.log(`Retrying expression as lower case:`, expression);
×
528
            variablePath = expression === '' ? [] : util.getVariablePath(expression?.toLowerCase());
×
529
            response = await this.socketDebugger.getVariables(variablePath, frame.frameIndex, frame.threadIndex);
×
530
        }
531
        return response;
2✔
532
    }
533

534
    /**
535
     * Get the variable for the specified expression.
536
     */
537
    public async getVariable(expression: string, frameId: number) {
538
        const response = await this.getVariablesResponse(expression, frameId);
1✔
539

540
        if (Array.isArray(response?.data?.variables)) {
1!
541
            const container = this.createEvaluateContainer(
1✔
542
                response.data.variables[0],
543
                //the name of the top container is the expression itself
544
                expression,
545
                //this is the top-level container, so there are no parent keys to this entry
546
                undefined
547
            );
548
            return container;
1✔
549
        }
550
    }
551

552
    /**
553
     * Get the list of local variables
554
     */
555
    public async getLocalVariables(frameId: number) {
556
        const response = await this.getVariablesResponse('', frameId);
1✔
557

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

580
    /**
581
     * Create an EvaluateContainer for the given variable. If the variable has children, those are created and attached as well
582
     * @param variable a Variable object from the debug protocol debugger
583
     * @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`)
584
     * @param parentEvaluateName the string used to derive the parent, _excluding_ this variable's name (i.e. `alpha.beta` or `alpha[0]`)
585
     */
586
    private createEvaluateContainer(variable: Variable, name: string, parentEvaluateName: string) {
587
        let value;
588
        let variableType = variable.type;
6✔
589
        if (variable.value === null) {
6!
590
            value = 'roInvalid';
×
591
        } else if (variableType === 'String') {
6✔
592
            value = `\"${variable.value}\"`;
2✔
593
        } else {
594
            value = variable.value;
4✔
595
        }
596

597
        if (variableType === VariableType.SubtypedObject) {
6!
598
            //subtyped objects can only have string values
599
            let parts = (variable.value as string).split('; ');
×
600
            (variableType as string) = `${parts[0]} (${parts[1]})`;
×
601
        } else if (variableType === VariableType.AssociativeArray) {
6✔
602
            variableType = VariableType.AssociativeArray;
3✔
603
        }
604

605
        //build full evaluate name for this var. (i.e. `alpha["beta"]` + ["charlie"]` === `alpha["beta"]["charlie"]`)
606
        let evaluateName: string;
607
        if (!parentEvaluateName?.trim()) {
6✔
608
            evaluateName = name;
4✔
609
        } else if (typeof name === 'string') {
2!
610
            evaluateName = `${parentEvaluateName}["${name}"]`;
2✔
611
        } else if (typeof name === 'number') {
×
612
            evaluateName = `${parentEvaluateName}[${name}]`;
×
613
        }
614

615
        let container: EvaluateContainer = {
6✔
616
            name: name ?? '',
18✔
617
            evaluateName: evaluateName ?? '',
18✔
618
            type: variableType ?? '',
18!
619
            value: value ?? null,
18✔
620
            highLevelType: undefined,
621
            //non object/array variables don't have a key type
622
            keyType: variable.keyType as unknown as KeyType,
623
            elementCount: variable.childCount ?? variable.children?.length ?? undefined,
51✔
624
            //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
625
            children: []
626
        };
627

628
        //recursively generate children containers
629
        if ([KeyType.integer, KeyType.string].includes(container.keyType) && Array.isArray(variable.children)) {
6✔
630
            container.children = [];
2✔
631
            for (let i = 0; i < variable.children.length; i++) {
2✔
632
                const childVariable = variable.children[i];
4✔
633
                const childContainer = this.createEvaluateContainer(
4✔
634
                    childVariable,
635
                    container.keyType === KeyType.integer ? i.toString() : childVariable.name,
4!
636
                    container.evaluateName
637
                );
638
                container.children.push(childContainer);
4✔
639
            }
640
        }
641
        return container;
6✔
642
    }
643

644
    /**
645
     * Cache items by a unique key
646
     * @param expression
647
     * @param factory
648
     */
649
    private resolve<T>(key: string, factory: () => T | Thenable<T>): Promise<T> {
650
        if (this.cache[key]) {
15✔
651
            this.logger.log('return cashed response', key, this.cache[key]);
2✔
652
            return this.cache[key];
2✔
653
        }
654
        this.cache[key] = Promise.resolve<T>(factory());
13✔
655
        return this.cache[key];
13✔
656
    }
657

658
    /**
659
     * Get a list of threads. The active thread will always be first in the list.
660
     */
661
    public async getThreads() {
662
        if (!this.isAtDebuggerPrompt) {
7!
663
            throw new Error('Cannot get threads: debugger is not paused');
×
664
        }
665
        return this.resolve('threads', async () => {
7✔
666
            let threads: Thread[] = [];
6✔
667
            let threadsResponse = await this.socketDebugger.threads();
6✔
668

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

689
            return threads;
6✔
690
        });
691
    }
692

693
    private async getThreadByThreadId(threadId: number) {
694
        let threads = await this.getThreads();
7✔
695
        for (let thread of threads) {
7✔
696
            if (thread.threadId === threadId) {
7✔
697
                return thread;
6✔
698
            }
699
        }
700
    }
701

702
    public removeAllListeners() {
703
        this.emitter?.removeAllListeners();
×
704
    }
705

706
    /**
707
     * Disconnect from the telnet session and unset all objects
708
     */
709
    public async destroy() {
710
        if (this.socketDebugger) {
×
711
            // destroy might be called due to a compile error so the socket debugger might not exist yet
712
            await this.socketDebugger.exitChannel();
×
713
        }
714

715
        this.cache = undefined;
×
716
        if (this.emitter) {
×
717
            this.emitter.removeAllListeners();
×
718
        }
719
        this.emitter = undefined;
×
720
    }
721

722
    // #region Rendezvous Tracker pass though functions
723
    /**
724
     * Passes the debug functions used to locate the client files and lines to the RendezvousTracker
725
     */
726
    public registerSourceLocator(sourceLocator: (debuggerPath: string, lineNumber: number) => Promise<SourceLocation>) {
727
        this.rendezvousTracker.registerSourceLocator(sourceLocator);
×
728
    }
729

730
    /**
731
     * Passes the log level down to the RendezvousTracker and ChanperfTracker
732
     * @param outputLevel the consoleOutput from the launch config
733
     */
734
    public setConsoleOutput(outputLevel: string) {
735
        this.chanperfTracker.setConsoleOutput(outputLevel);
×
736
        this.rendezvousTracker.setConsoleOutput(outputLevel);
×
737
    }
738

739
    /**
740
     * Sends a call to the RendezvousTracker to clear the current rendezvous history
741
     */
742
    public clearRendezvousHistory() {
743
        this.rendezvousTracker.clearHistory();
×
744
    }
745

746
    /**
747
     * Sends a call to the ChanperfTracker to clear the current chanperf history
748
     */
749
    public clearChanperfHistory() {
750
        this.chanperfTracker.clearHistory();
×
751
    }
752
    // #endregion
753

754
    public async syncBreakpoints() {
755
        //we can't send breakpoints unless we're stopped (or in a protocol version that supports sending them while running).
756
        //So...if we're not stopped, quit now. (we'll get called again when the stop event happens)
757
        if (!this.socketDebugger.supportsBreakpointRegistrationWhileRunning && !this.isAtDebuggerPrompt) {
3!
758
            return;
×
759
        }
760

761
        //compute breakpoint changes since last sync
762
        const diff = await this.breakpointManager.getDiff(this.projectManager.getAllProjects());
3✔
763

764
        //delete these breakpoints
765
        if (diff.removed.length > 0) {
3!
766
            await this.actionQueue.run(async () => {
×
767
                const response = await this.socketDebugger.removeBreakpoints(
×
768
                    diff.removed.map(x => x.deviceId)
×
769
                );
770
                //return true to mark this action as complete, or false to retry the task again in the future
771
                return response.success && response.data.errorCode === ErrorCode.OK;
×
772
            });
773
        }
774

775
        if (diff.added.length > 0) {
3✔
776
            const breakpointsToSendToDevice = diff.added.map(breakpoint => {
2✔
777
                const hitCount = parseInt(breakpoint.hitCondition);
2✔
778
                return {
2✔
779
                    filePath: breakpoint.pkgPath,
780
                    lineNumber: breakpoint.line,
781
                    hitCount: !isNaN(hitCount) ? hitCount : undefined,
2!
782
                    conditionalExpression: breakpoint.condition,
783
                    key: breakpoint.hash,
784
                    componentLibraryName: breakpoint.componentLibraryName
785
                };
786
            });
787

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

825
    private actionQueue = new ActionQueue();
6✔
826
}
827

828
export interface StackFrame {
829
    frameId: number;
830
    frameIndex: number;
831
    threadIndex: number;
832
    filePath: string;
833
    lineNumber: number;
834
    functionIdentifier: string;
835
}
836

837
export enum EventName {
1✔
838
    suspend = 'suspend'
1✔
839
}
840

841
export interface EvaluateContainer {
842
    name: string;
843
    evaluateName: string;
844
    type: string;
845
    value: string;
846
    keyType?: KeyType;
847
    elementCount: number;
848
    highLevelType: HighLevelType;
849
    children: EvaluateContainer[];
850
    presentationHint?: 'property' | 'method' | 'class' | 'data' | 'event' | 'baseClass' | 'innerClass' | 'interface' | 'mostDerivedClass' | 'virtual' | 'dataBreakpoint';
851
}
852

853
export enum KeyType {
1✔
854
    string = 'String',
1✔
855
    integer = 'Integer',
1✔
856
    legacy = 'Legacy'
1✔
857
}
858

859
export interface Thread {
860
    isSelected: boolean;
861
    lineNumber: number;
862
    filePath: string;
863
    functionName: string;
864
    lineContents: string;
865
    threadId: number;
866
}
867

868
interface BrightScriptRuntimeError {
869
    message: string;
870
    errorCode: string;
871
}
872

873
export function isDebugProtocolAdapter(adapter: TelnetAdapter | DebugProtocolAdapter): adapter is DebugProtocolAdapter {
1✔
874
    return adapter?.constructor.name === DebugProtocolAdapter.name;
1!
875
}
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