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

rokucommunity / roku-debug / 28254649616

26 Jun 2026 05:33PM UTC coverage: 72.796% (+0.1%) from 72.684%
28254649616

Pull #323

github

web-flow
Merge 0e2d1d7e2 into 025a474d2
Pull Request #323: apply postfix for Library statement

3754 of 5392 branches covered (69.62%)

Branch coverage included in aggregate %.

116 of 121 new or added lines in 5 files covered. (95.87%)

591 existing lines in 3 files now uncovered.

5941 of 7926 relevant lines covered (74.96%)

47.23 hits per line

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

66.1
/src/debugSession/BrightScriptDebugSession.ts
1
import * as fsExtra from 'fs-extra';
2✔
2
import { orderBy } from 'natural-orderby';
2✔
3
import * as path from 'path';
2✔
4
import * as semver from 'semver';
2✔
5
import { rokuDeploy, CompileError, isUpdateCheckRequiredError, isConnectionResetError, EcpNetworkAccessModeDisabledError } from 'roku-deploy';
2✔
6
import type { DeviceInfo, RokuDeploy, RokuDeployOptions } from 'roku-deploy';
7
import {
2✔
8
    BreakpointEvent,
9
    LoggingDebugSession,
10
    Logger as DapLogger,
11
    logger as dapLogger,
12
    CapabilitiesEvent,
13
    InitializedEvent,
14
    InvalidatedEvent,
15
    OutputEvent,
16
    ProgressEndEvent,
17
    ProgressStartEvent,
18
    ProgressUpdateEvent,
19
    Source,
20
    StackFrame,
21
    StoppedEvent,
22
    TerminatedEvent,
23
    Thread,
24
    Variable
25
} from '@vscode/debugadapter';
26
import type { SceneGraphCommandResponse } from '../SceneGraphDebugCommandController';
27
import { SceneGraphDebugCommandController } from '../SceneGraphDebugCommandController';
2✔
28
import type { DebugProtocol } from '@vscode/debugprotocol';
29
import { defer, util } from '../util';
2✔
30
import { fileUtils, standardizePath as s } from '../FileUtils';
2✔
31
import { ComponentLibraryServer } from '../ComponentLibraryServer';
2✔
32
import { ProjectManager, Project, ComponentLibraryProject } from '../managers/ProjectManager';
2✔
33
import type { EvaluateContainer, Thread as AdapterThread } from '../adapters/DebugProtocolAdapter';
34
import { DebugProtocolAdapter } from '../adapters/DebugProtocolAdapter';
2✔
35
import { TelnetAdapter } from '../adapters/TelnetAdapter';
2✔
36
import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
37
import { RendezvousTracker } from '../RendezvousTracker';
2✔
38
import {
2✔
39
    LaunchStartEvent,
40
    LogOutputEvent,
41
    RendezvousEvent,
42
    DiagnosticsEvent,
43
    StoppedEventReason,
44
    ChanperfEvent,
45
    DebugServerLogOutputEvent,
46
    ChannelPublishedEvent,
47
    CustomRequestEvent,
48
    ClientToServerCustomEventName,
49
    ProfilingErrorEvent,
50
    ProfilingStartEvent,
51
    ProfilingStopEvent,
52
    ProfilingEnabledEvent as ProfilingEnableEvent,
53
    ProcessCrashEvent
54
} from './Events';
55
import type { ProcessCrashEventData } from './Events';
56
import type { LaunchConfiguration, ComponentLibraryConfiguration } from '../LaunchConfiguration';
57
import { FileManager } from '../managers/FileManager';
2✔
58
import { SourceMapManager } from '../managers/SourceMapManager';
2✔
59
import { LocationManager } from '../managers/LocationManager';
2✔
60
import type { AugmentedSourceBreakpoint } from '../managers/BreakpointManager';
61
import { BreakpointManager } from '../managers/BreakpointManager';
2✔
62
import type { LogMessage } from '../logging';
63
import { PerfettoManager } from '../PerfettoManager';
2✔
64
import { logger, FileLoggingManager, debugServerLogOutputEventTransport, LogLevelPriority } from '../logging';
2✔
65
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
2✔
66
import { DiagnosticSeverity } from 'brighterscript';
2✔
67
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
68
import { debounce } from 'debounce';
2✔
69
import { interfaces, components, events } from 'brighterscript/dist/roku-types';
2✔
70
import { globalCallables } from 'brighterscript/dist/globalCallables';
2✔
71
import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool';
2✔
72
import { populateVariableFromRegistryEcp } from './ecpRegistryUtils';
2✔
73
import { AppState, rokuECP } from '../RokuECP';
2✔
74
import { SocketConnectionInUseError } from '../Exceptions';
2✔
75

76
const diagnosticSource = 'roku-debug';
2✔
77

78
/**
79
 * Sort tiers for debug-console completions. Lower values sort first, so a variable's own members rank
80
 * above interface methods, then the file's scope functions, and finally the (large) set of globals.
81
 */
82
enum CompletionSortTier {
2✔
83
    Member = '1',
2✔
84
    Method = '2',
2✔
85
    ScopeFunction = '3',
2✔
86
    Global = '4'
2✔
87
}
88

89
export class BrightScriptDebugSession extends LoggingDebugSession {
2✔
90
    public constructor() {
91
        super();
200✔
92

93
        // this debugger uses one-based lines and columns
94
        this.setDebuggerLinesStartAt1(false);
200✔
95
        this.setDebuggerColumnsStartAt1(false);
200✔
96

97
        //give util a reference to this session to assist in logging across the entire module
98
        util._debugSession = this;
200✔
99

100
        this.fileManager = new FileManager();
200✔
101
        this.sourceMapManager = new SourceMapManager();
200✔
102
        this.locationManager = new LocationManager(this.sourceMapManager);
200✔
103
        this.breakpointManager = new BreakpointManager(this.sourceMapManager, this.locationManager);
200✔
104
        //send newly-verified breakpoints to vscode
105
        this.breakpointManager.on('breakpoints-verified', (data) => this.onDeviceBreakpointsChanged('changed', data));
200✔
106
        this.projectManager = new ProjectManager({
200✔
107
            breakpointManager: this.breakpointManager,
108
            locationManager: this.locationManager
109
        });
110
        this.fileLoggingManager = new FileLoggingManager();
200✔
111
    }
112

113
    public start(inStream: NodeJS.ReadableStream, outStream: NodeJS.WritableStream): void {
114
        super.start(inStream, outStream);
2✔
115
        // Set up DAP protocol logging as early as possible — immediately after start() so we capture
116
        // the initialize request and all early DAP traffic before launchRequest config is available.
117
        // The log file path is injected as ROKU_DAP_LOG_FILE by the extension's DebugAdapterDescriptorFactory,
118
        // which resolves the path from the `brightscript.debug.debugAdapterProtocolLogging` workspace setting
119
        // (or the equivalent launch.json property) before the debug adapter process is spawned.
120
        const dapLogFile = process.env.ROKU_DAP_LOG_FILE;
2✔
121
        if (dapLogFile) {
2✔
122
            // Use LogLevel.Error (not Verbose) as the console threshold so DAP messages are written
123
            // to the log file but are NOT forwarded to VS Code as OutputEvents, which would flood
124
            // the debug console and break the extension's output parsing.
125
            // Note: InternalLogger always writes ALL messages to the file stream regardless of level,
126
            // so the log file will still contain everything.
127
            dapLogger.setup(DapLogger.LogLevel.Error, dapLogFile);
1✔
128
        }
129
    }
130

131
    public setupProcessErrorHandlers() {
132
        if (this.processErrorHandlersRegistered) {
16✔
133
            return;
1✔
134
        }
135
        this.processErrorHandlersRegistered = true;
15✔
136

137
        const handleError = (type: 'uncaughtException' | 'unhandledRejection', error: unknown) => {
15✔
138
            const logger = this.logger.createLogger(`${type}`);
14✔
139
            const message = error instanceof Error ? error.message : String(error);
14✔
140
            const stack = error instanceof Error ? error.stack : undefined;
14✔
141
            logger.error(message, stack);
14✔
142

143
            let output: string;
144
            let debuggerVersion: string;
145
            let additionalInfo: ProcessCrashEventData['additionalInfo'];
146
            try {
14✔
147
                debuggerVersion = (fsExtra.readJsonSync(path.resolve(__dirname, '../../package.json')) as { version: string }).version;
14✔
148

149
                const clientName = this.initRequestArgs?.clientName ?? 'unknown';
13✔
150

151
                additionalInfo = {
13✔
152
                    clientName: clientName,
153
                    rokuDebugVersion: debuggerVersion,
154
                    ecpMode: this.deviceInfo?.ecpSettingMode,
39!
155
                    developerMode: this.deviceInfo?.developerEnabled,
39!
156
                    firmware: this.deviceInfo ? `${this.deviceInfo?.softwareVersion}.${this.deviceInfo?.softwareBuild}` : undefined,
13!
157
                    protocolVersion: this.deviceInfo?.brightscriptDebuggerVersion,
39!
158
                    protocolEnabled: this.enableDebugProtocol
159
                };
160

161
                const lines = Object.entries(additionalInfo as Record<string, unknown>).map(([key, value]) => {
13✔
162
                    // Insert a space before all uppercase letters preceded by a lowercase letter, then uppercase the first char
163
                    const spacedString = key.replace(/([a-z])([A-Z])/g, '$1 $2');
91✔
164
                    const formattedKey = spacedString.charAt(0).toUpperCase() + spacedString.slice(1);
91✔
165
                    return `**${formattedKey}:** ${JSON.stringify(value)}`;
91✔
166
                });
167

168
                const issueBodyPrefix = [
13✔
169
                    `**Error type:** ${type}`,
170
                    `**Message:** ${message}`,
171
                    ...lines,
172
                    '',
173
                    `**Steps to reproduce:**`,
174
                    `<!-- Please describe what you were doing when this crash occurred -->`,
175
                    '',
176
                    '**Stack trace:**',
177
                    '```',
178
                    ''
179
                ].join('\n');
180
                const issueBodySuffix = '\n```';
13✔
181

182
                const issueTitle = encodeURIComponent(`[crash] ${type}: ${message}`);
13✔
183
                const baseUrl = 'https://github.com/RokuCommunity/roku-debug/issues/new';
13✔
184
                const maxUrlLength = 2000;
13✔
185
                const urlOverhead = `${baseUrl}?title=${issueTitle}&body=`.length;
13✔
186
                const bodyBudget = maxUrlLength - urlOverhead;
13✔
187
                const encodedPrefix = encodeURIComponent(issueBodyPrefix);
13✔
188
                const encodedSuffix = encodeURIComponent(issueBodySuffix);
13✔
189
                const stackBudget = bodyBudget - encodedPrefix.length - encodedSuffix.length;
13✔
190
                let truncatedStack: string;
191
                if (!stack) {
13✔
192
                    truncatedStack = '(no stack trace)';
2✔
193
                } else if (encodeURIComponent(stack).length <= stackBudget) {
11✔
194
                    truncatedStack = stack;
3✔
195
                } else {
196
                    truncatedStack = decodeURIComponent(encodeURIComponent(stack).slice(0, stackBudget)) + '\n...(truncated)';
8✔
197
                }
198
                const issueUrl = `${baseUrl}?title=${issueTitle}&body=${encodedPrefix}${encodeURIComponent(truncatedStack)}${encodedSuffix}`;
10✔
199

200
                output = [
10✔
201
                    '',
202
                    '================================================================',
203
                    '\tBRIGHTSCRIPT DEBUGGER INTERNAL ERROR',
204
                    '\tThis is a crash in the debug adapter, not in your application.',
205
                    '================================================================',
206
                    `\tError type: ${type}`,
207
                    `\tMessage: ${message}`,
208
                    ...lines.map(l => `\t${l}`),
70✔
209
                    '',
210
                    '\tStack trace:',
211
                    ...(stack ?? '(no stack trace)').split('\n').map(l => `\t${l}`),
73✔
212
                    '',
213
                    '\tPlease report this at:',
214
                    `\t${issueUrl}`,
215
                    '================================================================',
216
                    ''
217
                ].join('\n');
218
            } catch (e) {
219
                output = JSON.stringify({
4✔
220
                    name: e.name,
221
                    message: e.message,
222
                    stack: e.stack
223
                });
224
            }
225

226
            void this.sendLogOutput(output).catch(() => { /** best-effort */ });
14✔
227
            this.isCrashed = true;
14✔
228
            this.sendEvent(new ProcessCrashEvent({ type, message, stack, additionalInfo: additionalInfo ?? {} }));
14✔
229
            setTimeout(() => void this.shutdown(), 5000);
14✔
230
        };
231

232
        this._uncaughtExceptionHandler = (error) => handleError('uncaughtException', error);
15✔
233
        this._unhandledRejectionHandler = (reason) => handleError('unhandledRejection', reason);
15✔
234

235
        process.on('uncaughtException', this._uncaughtExceptionHandler);
15✔
236
        process.on('unhandledRejection', this._unhandledRejectionHandler);
15✔
237
    }
238

239
    public teardownProcessErrorHandlers() {
240
        if (this._uncaughtExceptionHandler) {
30✔
241
            process.removeListener('uncaughtException', this._uncaughtExceptionHandler);
15✔
242
            this._uncaughtExceptionHandler = undefined;
15✔
243
        }
244
        if (this._unhandledRejectionHandler) {
30✔
245
            process.removeListener('unhandledRejection', this._unhandledRejectionHandler);
15✔
246
            this._unhandledRejectionHandler = undefined;
15✔
247
        }
248
        this.processErrorHandlersRegistered = false;
30✔
249
    }
250

251
    private onDeviceBreakpointsChanged(eventName: 'changed' | 'new', data: { breakpoints: AugmentedSourceBreakpoint[] }) {
252
        this.logger.info('Sending verified device breakpoints to client', data);
3✔
253
        //send all verified breakpoints to the client
254
        for (const breakpoint of data.breakpoints) {
3✔
255
            const event: DebugProtocol.Breakpoint = {
3✔
256
                line: breakpoint.line,
257
                column: breakpoint.column,
258
                verified: breakpoint.verified,
259
                id: breakpoint.id,
260
                reason: breakpoint.reason,
261
                message: breakpoint.message,
262
                source: {
263
                    path: breakpoint.srcPath
264
                }
265
            };
266
            this.sendEvent(new BreakpointEvent(eventName, event));
3✔
267
        }
268
    }
269

270
    public logger = logger.createLogger(`[session]`);
200✔
271

272
    private readonly isWindowsPlatform = process.platform.startsWith('win');
200✔
273

274
    /**
275
     * A sequence used to help identify log statements for requests
276
     */
277
    private idCounter = 1;
200✔
278

279
    public fileManager: FileManager;
280

281
    public projectManager: ProjectManager;
282

283
    public fileLoggingManager: FileLoggingManager;
284

285
    private processErrorHandlersRegistered = false;
200✔
286
    private isCrashed = false;
200✔
287
    private _uncaughtExceptionHandler: ((error: Error) => void) | undefined;
288
    private _unhandledRejectionHandler: ((reason: unknown) => void) | undefined;
289

290
    public breakpointManager: BreakpointManager;
291

292
    public locationManager: LocationManager;
293

294
    public sourceMapManager: SourceMapManager;
295

296
    //set imports as class properties so they can be spied upon during testing
297
    public rokuDeploy = rokuDeploy as unknown as RokuDeploy;
200✔
298

299
    private componentLibraryServer = new ComponentLibraryServer();
200✔
300

301
    private rokuAdapterDeferred = defer<DebugProtocolAdapter | TelnetAdapter>();
200✔
302
    /**
303
     * A promise that is resolved whenever the app has started running for the first time
304
     */
305
    private firstRunDeferred = defer<void>();
200✔
306

307
    /**
308
     * Resolved whenever we're finished copying all the files to staging for all projects
309
     */
310
    private stagingDefered = defer<void>();
200✔
311

312
    private evaluateRefIdLookup: Record<string, number> = {};
200✔
313
    private evaluateRefIdCounter = 1;
200✔
314

315
    private variables: Record<number, AugmentedVariable> = {};
200✔
316

317
    /**
318
     * Caches the device lookups performed while resolving completion requests. Variables don't change
319
     * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
320
     * `clearState` whenever the debugger resumes or steps.
321
     */
322
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
200✔
323

324
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
325

326
    private perfettoManager: PerfettoManager;
327

328
    private rendezvousTracker: RendezvousTracker;
329

330
    public tempVarPrefix = '__rokudebug__';
200✔
331

332
    /**
333
     * The progressId of the active launch progress bar, if any.
334
     * Cleared once the ProgressEndEvent is sent.
335
     */
336
    private launchProgressId: string | undefined;
337

338
    /**
339
     * The first encountered compile error, will be used to send to the client as a runtime error (nicer UI presentation)
340
     */
341
    private compileError: BSDebugDiagnostic;
342

343
    /**
344
     * A magic number to represent a fake thread that will be used for showing compile errors in the UI as if they were runtime crashes
345
     */
346
    private COMPILE_ERROR_THREAD_ID = 7_777;
200✔
347

348
    private get enableDebugProtocol() {
349
        return this.launchConfiguration?.enableDebugProtocol;
72!
350
    }
351

352
    /**
353
     * Check if the Roku firmware supports Perfetto tracing (requires OS 15.2 or higher)
354
     */
355
    private get supportsPerfettoTracing() {
356
        this.logger.log('Checking if device supports Perfetto tracing', this.deviceInfo.softwareVersion);
13✔
357
        return semver.satisfies(this.deviceInfo?.softwareVersion ?? '0.0', '>= 15.2');
13!
358
    }
359

360
    /**
361
     * Get a promise that resolves when the roku adapter is ready to be used
362
     */
363
    private async getRokuAdapter() {
364
        await this.rokuAdapterDeferred.promise;
26✔
365
        await this.rokuAdapter.onReady();
26✔
366
        return this.rokuAdapter;
26✔
367
    }
368

369
    private launchConfiguration: LaunchConfiguration;
370
    private initRequestArgs: DebugProtocol.InitializeRequestArguments;
371

372
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
200✔
373

374
    /**
375
     * The 'initialize' request is the first request called by the frontend
376
     * to interrogate the features the debug adapter provides.
377
     */
378
    public initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void {
379
        this.initRequestArgs = args;
2✔
380
        this.logger.log('initializeRequest');
2✔
381

382
        response.body ||= {};
2✔
383

384
        // This debug adapter implements the configurationDoneRequest.
385
        response.body.supportsConfigurationDoneRequest = true;
2✔
386

387
        // The debug adapter supports the 'restart' request. In this case a client should not implement 'restart' by terminating and relaunching the adapter but by calling the RestartRequest.
388
        response.body.supportsRestartRequest = true;
2✔
389

390
        // make VS Code to use 'evaluate' when hovering over source
391
        response.body.supportsEvaluateForHovers = true;
2✔
392

393
        //NOTE: `supportsConditionalBreakpoints` and `supportsHitConditionalBreakpoints` are
394
        //sent later in the post-connect CapabilitiesEvent once we know which adapter is in use
395
        //(telnet always supports them via stop-statement rewrites; debug protocol requires v3.1.0+).
396
        //VS Code reads these caps per-render in the BREAKPOINTS view, so CapabilitiesEvent updates
397
        //take effect immediately - unlike `exceptionBreakpointFilters` / `breakpointModes` which
398
        //must be in the initialize response.
399

400
        //surface the filter list here so VS Code's BREAKPOINTS panel renders the checkboxes - the
401
        //panel only reads this list from the initialize response, not from later CapabilitiesEvents.
402
        //The `supportsExceptionFilterOptions` / `supportsExceptionOptions` booleans are deferred and
403
        //sent via CapabilitiesEvent once we know the connected device's protocol version.
404
        response.body.exceptionBreakpointFilters = [{
2✔
405
            filter: 'caught',
406
            supportsCondition: true,
407
            conditionDescription: '__brs_err__.rethrown = true',
408
            label: 'Caught Exceptions',
409
            description: `Breaks on all errors, even if they're caught later.`,
410
            default: false
411
        }, {
412
            filter: 'uncaught',
413
            supportsCondition: true,
414
            conditionDescription: '__brs_err__.rethrown = true',
415
            label: 'Uncaught Exceptions',
416
            description: 'Breaks only on errors that are not handled.',
417
            default: true
418
        }];
419

420
        response.body.supportsCompletionsRequest = true;
2✔
421
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
2✔
422

423
        this.sendResponse(response);
2✔
424

425
        //register the debug output log transport writer
426
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
2✔
427
            this.sendEvent(
538✔
428
                new DebugServerLogOutputEvent(
429
                    message.logger.formatMessage(message, false)
430
                )
431
            );
432
        });
433

434
        this.logger.log('initializeRequest finished');
2✔
435
    }
436

437
    protected async setExceptionBreakPointsRequest(response: DebugProtocol.SetExceptionBreakpointsResponse, args: DebugProtocol.SetExceptionBreakpointsArguments) {
438
        response.body ??= {};
9!
439
        try {
9✔
440

441
            let filterOptions: ExceptionBreakpoint[];
442
            if (args.filterOptions) {
9✔
443
                filterOptions = args.filterOptions.map(x => ({
2✔
444
                    filter: x.filterId as 'caught' | 'uncaught',
445
                    conditionExpression: x.condition
446
                }));
447
            } else if (args.filters) {
7✔
448
                filterOptions = args.filters.map(x => ({
9✔
449
                    filter: x as 'caught' | 'uncaught'
450
                }));
451
            }
452
            this.exceptionBreakpoints = filterOptions;
9✔
453

454
            //wait until the adapter object exists, but don't wait for the device to come online —
455
            //VS Code will not send configurationDone (and we cannot launch the channel) until we
456
            //respond to this request.
457
            await this.rokuAdapterDeferred.promise;
9✔
458

459
            if (this.rokuAdapter.supportsExceptionBreakpoints) {
9✔
460
                //the adapter queues these filters internally if the debug protocol client hasn't
461
                //connected yet, and replays them once it does
462
                await this.rokuAdapter.setExceptionBreakpoints(filterOptions);
8✔
463
                response.body.breakpoints = [
7✔
464
                    { verified: true },
465
                    { verified: true }
466
                ];
467
            } else {
468
                response.body.breakpoints = [
1✔
469
                    { verified: false },
470
                    { verified: false }
471
                ];
472
            }
473
        } catch (e) {
474
            //if error (or not supported)
475
            response.body.breakpoints = [
1✔
476
                { verified: false },
477
                { verified: false }
478
            ];
479
            this.logger.error('Failed to set exception breakpoints', e);
1✔
480
        } finally {
481
            this.sendResponse(response);
9✔
482
        }
483
    }
484

485

486
    protected async setTransientsToInvalid() {
487
        let brsErr = Object.values(this.variables).find((v) => v.name === '__brs_err__');
×
488
        if (brsErr && brsErr.type !== VariableType.Uninitialized) {
×
489
            // Assigning the variable to the function call results in it becoming unintialized
490
            await this.rokuAdapter.evaluate(`__brs_err__ = [].clear()`, brsErr.frameId);
×
491
        }
492
    }
493

494
    private async showPopupMessage<T extends string>(message: string, severity: 'error' | 'warn' | 'info', modal = false, actions?: T[]): Promise<T> {
4✔
495
        const response = await this.sendCustomRequest('showPopupMessage', { message: message, severity: severity, modal: modal, actions: actions });
4✔
496
        return response.selectedAction;
2✔
497
    }
498

499
    private static requestIdSequence = 0;
2✔
500

501
    private async sendCustomRequest<T = any, R = any>(name: string, data: T): Promise<R> {
502
        const requestId = BrightScriptDebugSession.requestIdSequence++;
3✔
503
        const responsePromise = new Promise<R>((resolve, reject) => {
3✔
504
            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
3✔
505
                if (response.requestId === requestId) {
1!
506
                    if (response.error) {
1!
507
                        throw response.error;
×
508
                    } else {
509
                        resolve(response as R);
1✔
510
                    }
511
                }
512
            });
513
        });
514
        this.sendEvent(
3✔
515
            new CustomRequestEvent({
516
                requestId: requestId,
517
                name: name,
518
                ...data ?? {}
9!
519
            }));
520
        return responsePromise;
3✔
521
    }
522

523
    /**
524
      * Get the cwd from the launchConfiguration, or default to process.cwd()
525
      */
526
    private get cwd() {
527
        return this.launchConfiguration?.cwd ?? process.cwd();
7!
528
    }
529

530
    public deviceInfo: DeviceInfo;
531

532
    /**
533
     * Set defaults and standardize values for all of the LaunchConfiguration values
534
     * @param config
535
     * @returns
536
     */
537
    private normalizeLaunchConfig(config: LaunchConfiguration) {
538
        config.cwd ??= process.cwd();
7✔
539
        config.outDir ??= s`${config.cwd}/out`;
7✔
540
        config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`;
7!
541
        config.componentLibrariesPort ??= 8080;
7!
542
        config.packagePort ??= 80;
7!
543
        config.remotePort ??= 8060;
7!
544
        config.sceneGraphDebugCommandsPort ??= 8080;
7!
545
        config.controlPort ??= 8081;
7!
546
        config.brightScriptConsolePort ??= 8085;
7!
547
        config.stagingDir ??= config.stagingFolderPath;
7!
548
        config.emitChannelPublishedEvent ??= true;
7!
549
        config.rewriteDevicePathsInLogs ??= true;
7!
550
        config.autoResolveVirtualVariables ??= false;
7!
551
        config.enhanceREPLCompletions ??= true;
7!
552
        config.username ??= 'rokudev';
7!
553
        if (config.profiling?.tracing?.enable) {
7!
554
            config.profiling.tracing.dir ??= s`${config.cwd}/traces/`;
×
555
            // eslint-disable-next-line no-template-curly-in-string
556
            config.profiling.tracing.filename ??= '${appTitle}_${timestamp}.perfetto-trace';
×
557
        }
558

559
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
560
        if (typeof config.enableVariablesPanel !== 'boolean') {
7!
561
            config.enableVariablesPanel = true;
7✔
562
        }
563
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
7!
564
        return config;
7✔
565
    }
566

567
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
568
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
7✔
569

570
        try {
7✔
571
            this.resetSessionState();
7✔
572
            this.launchConfiguration = this.normalizeLaunchConfig(config);
7✔
573
            this.setupProcessErrorHandlers();
7✔
574

575
            //prebake some threads for our ProjectManager to use later on (1 for the main project, and 1 for every complib)
576
            bscProjectWorkerPool.preload(1 + (this.launchConfiguration?.componentLibraries?.length ?? 0));
7!
577

578
            //set the logLevel provided by the launch config
579
            if (this.launchConfiguration.logLevel) {
7!
580
                logger.logLevel = this.launchConfiguration.logLevel;
×
581
            }
582

583
            this.sendLaunchProgress('start', 'Finding device on network');
7✔
584

585
            //do a DNS lookup for the host to fix issues with roku rejecting ECP
586
            try {
7✔
587
                this.launchConfiguration.host = await util.dnsLookup(this.launchConfiguration.host);
7✔
588
            } catch (e) {
589
                return this.shutdown(`Could not resolve ip address for host '${this.launchConfiguration.host}'`);
×
590
            }
591

592
            // fetches the device info and parses the xml data to JSON object
593
            try {
7✔
594
                this.deviceInfo = await rokuDeploy.getDeviceInfo({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
7✔
595
                if (this.deviceInfo.ecpSettingMode === 'limited') {
7!
596
                    return await this.shutdown(`ECP access is limited on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
597
                }
598
            } catch (e) {
599
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
600
                    return this.shutdown(`ECP access is disabled on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
601
                }
602
                return this.shutdown(`Unable to connect to roku at '${this.launchConfiguration.host}'. Verify the IP address is correct and that the device is powered on and connected to same network as this computer.`);
×
603
            }
604

605
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
7!
606
                return await this.shutdown(`Developer mode is not enabled for host '${this.launchConfiguration.host}'.`);
×
607
            }
608

609
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
610
            this.sendResponse(response);
7✔
611

612
            //initialize all file logging (rokuDevice, debugger, etc)
613
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
7!
614

615
            this.projectManager.launchConfiguration = this.launchConfiguration;
7✔
616
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
7✔
617

618
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
7✔
619

620
            this.logger.log('[launchRequest] Packaging and deploying to roku');
7✔
621
            const packageEnd = this.logger.timeStart('log', 'Packaging');
7✔
622
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
7!
623
            //build the main project and all component libraries at the same time
624
            await Promise.all([
7✔
625
                this.prepareMainProject(),
626
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
627
            ]);
628

629
            //all of the projects have been successfully staged.
630
            this.stagingDefered.tryResolve();
7✔
631

632
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
633
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
7!
634
                await this.sendCustomRequest('processStagingDir', {
×
635
                    projects: this.projectManager.getProjectStagingInfo()
636
                });
637
            }
638

639
            packageEnd();
7✔
640

641
            if (this.enableDebugProtocol) {
7!
642
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.launchConfiguration.host}:${this.launchConfiguration.controlPort}`);
×
643
            } else {
644
                util.log(`Connecting to Roku via telnet at ${this.launchConfiguration.host}:${this.launchConfiguration.brightScriptConsolePort}`);
7✔
645
            }
646

647
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
648
            try {
7✔
649
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
7✔
650
                await this.initRendezvousTracking();
7✔
651
                rendezvousEnd();
7✔
652
            } catch (e) {
653
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
654
            }
655

656
            this.sendLaunchProgress('update', 'Connecting to debug server');
7✔
657
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
7✔
658
            this.createRokuAdapter(this.rendezvousTracker);
7✔
659
            await this.connectRokuAdapter();
7✔
660
            connectAdapterEnd();
7✔
661

662
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
663
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
664
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
665
            // effect dynamically.
666
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
7✔
667
            this.sendEvent(new CapabilitiesEvent({
7✔
668
                supportsLogPoints: !this.enableDebugProtocol,
669
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
670
                supportsExceptionOptions: supportsExceptionBreakpoints,
671
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
672
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
673
            }));
674

675
            this.sendLaunchProgress('update', 'Configuring breakpoints');
7✔
676

677
            util.log('Done initializing');
7✔
678

679
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
680
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
681
            this.sendEvent(new InitializedEvent());
7✔
682

683
            await this.initializeProfiling();
7✔
684

685
        } catch (e) {
686
            //if the message is anything other than compile errors, we want to display the error
687
            if (!(e instanceof CompileError)) {
×
688
                util.log('Encountered an issue during the launch process');
×
689
                util.log((e as Error)?.stack);
×
690

691
                //send any compile errors to the client
692
                await this.rokuAdapter?.sendErrors();
×
693

694
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
695
                await this.shutdown(message as string, true);
×
696
            } else {
697
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
698
            }
699
        }
700
        logEnd();
7✔
701
    }
702

703
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
704
        this.logger.log('configurationDoneRequest');
4✔
705
        super.configurationDoneRequest(response, args);
4✔
706

707
        let error: Error;
708
        try {
4✔
709
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
4✔
710

711
            //press the home button to ensure we're at the home screen
712
            await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
4✔
713

714
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
715
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
4✔
716

717
            //pass along the console output
718
            if (this.launchConfiguration.consoleOutput === 'full') {
4!
719
                this.rokuAdapter.on('console-output', (data) => {
×
720
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
721
                });
722
            } else {
723
                this.rokuAdapter.on('unhandled-console-output', (data) => {
4✔
724
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
725
                });
726
            }
727

728
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
4✔
729
                const stopDebuggerAction = 'Stop Debugger';
×
730
                const message = `Roku device ${this.launchConfiguration.host} is not responding and may not recover.` +
×
731
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
×
732
                this.logger.log(message, data);
×
733
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
734
                if (response === stopDebuggerAction) {
×
735
                    await this.shutdown();
×
736
                }
737
            });
738

739
            // Send chanperf events to the extension
740
            this.rokuAdapter.on('chanperf', (output) => {
4✔
741
                this.sendEvent(new ChanperfEvent(output));
×
742
            });
743

744
            //listen for a closed connection (shut down when received)
745
            this.rokuAdapter.on('close', (reason = '') => {
4!
746
                if (reason === 'compileErrors') {
×
747
                    error = new Error('compileErrors');
×
748
                } else {
749
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
×
750
                }
751
            });
752

753
            // handle any compile errors
754
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
4✔
755
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
×
756
            });
757

758
            // close disconnect if required when the app is exited
759
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
760
            this.rokuAdapter.on('app-exit', async () => {
4✔
761
                this.resetSessionState();
×
762

763
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
764
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
765
                    message += ' - shutting down debug session';
×
766

767
                    this.logger.log('on app-exit', message);
×
768
                    this.sendEvent(new LogOutputEvent(message));
×
769
                    await this.shutdown();
×
770
                } else {
771
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
×
772
                    this.logger.log('[configurationDoneRequest]', message);
×
773
                    this.sendEvent(new LogOutputEvent(message));
×
774
                    this.rokuAdapter.once('connected').then(async () => {
×
775
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
×
776
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
×
777
                }
778
            });
779
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
780
            await this.tryProfilingConnectOnStart();
4✔
781

782
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
783
            //that the client has finished sending configuration). Inject the STOPs and postfix each project's
784
            //own files now (still no zips — those are sealed after cross-project references are rewritten).
785
            await Promise.all([
4✔
786
                this.writeMainProjectBreakpoints(),
787
                this.writeAndPostfixComponentLibraries(this.launchConfiguration.componentLibraries)
788
            ]);
789

790
            //now that EVERY project (main + all complibs) is postfixed, rewrite cross-project `Library`
791
            //references to point at the postfixed file names. Must run before any project zip is sealed.
792
            await this.projectManager.applyLibraryReferencePostfixes();
4✔
793

794
            //references are fixed — seal the main project zip and the complib zips (then install + host)
795
            await Promise.all([
4✔
796
                this.zipMainProject(),
797
                this.zipAndHostComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
798
            ]);
799

800
            this.sendLaunchProgress('update', 'Uploading to Roku');
4✔
801
            await this.publish();
4✔
802

803
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
804
            if (this.launchConfiguration.emitChannelPublishedEvent) {
3!
805
                this.sendEvent(new ChannelPublishedEvent(
3✔
806
                    this.launchConfiguration
807
                ));
808
            }
809

810
            //tell the adapter adapter that the channel has been launched.
811
            this.sendLaunchProgress('update', 'Waiting on application');
3✔
812
            await this.rokuAdapter.activate();
3✔
813
            if (this.rokuAdapter.isDestroyed) {
3!
814
                throw new Error('Debug session encountered an error');
×
815
            }
816
            if (!error) {
3!
817
                if (this.rokuAdapter.connected) {
3!
818
                    this.logger.info('Host connection was established before the main public process was completed');
3✔
819
                    this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
3✔
820
                } else {
821
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
822
                    this.rokuAdapter.on('connected', (status) => {
×
823
                        if (status) {
×
824
                            this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
×
825
                        }
826
                    });
827
                }
828
            } else {
829
                throw error;
×
830
            }
831

832
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
833
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
3!
834
                //wait until the first entry breakpoint has been hit
835
                await this.firstRunDeferred.promise;
×
836
                //if we are at a breakpoint, continue
837
                await this.rokuAdapter.continue();
×
838
                //kill the app on the roku
839
                // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
840
                //convert a hostname to an ip address
841
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
842
                //send the deep link http request
843
                await util.httpPost(deepLinkUrl);
×
844
            }
845

846
        } catch (e) {
847
            //if the message is anything other than compile errors, we want to display the error
848
            if (!(e instanceof CompileError)) {
1!
849
                util.log('Encountered an issue during the publish process');
×
850
                util.log((e as Error)?.stack);
×
851

852
                //send any compile errors to the client
853
                await this.rokuAdapter?.sendErrors();
×
854

855
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
856
                await this.shutdown(message as string, true);
×
857
            } else {
858
                this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
859
            }
860
        }
861
    }
862

863
    /**
864
     * Activate all required functionality for profiling
865
     */
866
    private async initializeProfiling() {
867

868
        // Initialize PerfettoManager
869
        this.perfettoManager = new PerfettoManager({
16✔
870
            host: this.launchConfiguration.host,
871
            rootDir: this.launchConfiguration.rootDir,
872
            remotePort: this.launchConfiguration.remotePort,
873
            ...this.launchConfiguration.profiling?.tracing
48✔
874
        });
875

876
        //send certain profiling events back to the client
877
        this.perfettoManager.on('enable', (event) => {
16✔
878
            this.sendEvent(new ProfilingEnableEvent({
×
879
                types: event.types
880
            }));
881
        });
882
        this.perfettoManager.on('start', (event) => {
16✔
883
            this.sendEvent(new ProfilingStartEvent({
×
884
                type: event.type
885
            }));
886
        });
887
        this.perfettoManager.on('stop', (event) => {
16✔
888
            this.sendEvent(new ProfilingStopEvent({
×
889
                type: event.type,
890
                result: event.result
891
            }));
892
        });
893
        this.perfettoManager.on('error', (event) => {
16✔
894
            this.sendEvent(new ProfilingErrorEvent({
×
895
                error: event.error
896
            }));
897
        });
898

899
        //tracing is explicitly enabled. Turn it on
900
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
16✔
901
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
2✔
902
            try {
2✔
903
                await this.perfettoManager.enableTracing();
2✔
904
            } catch (e) {
905
                this.logger.error('Failed to enable perfetto tracing', e);
1✔
906
            }
907

908
            //tracing is expicitly DISabled. turn it off
909
        } else if (this.launchConfiguration.profiling?.tracing?.enable === false && this.supportsPerfettoTracing) {
14✔
910
            this.logger.info('Disabling perfetto tracing because it is disabled in the launch configuration');
2✔
911
            //TODO implement a way to disable perfetto tracing on the device
912

913
            //tracing was requested but the device firmware does not meet the minimum requirement
914
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
12✔
915
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
2!
916
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). The Perfetto profiling buttons will not be available during this session.`;
2✔
917
            this.logger.warn(message);
2✔
918
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
2✔
919

920
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
921
        } else {
922
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
10✔
923
        }
924
    }
925

926
    /**
927
     * If profiling was marked "connectOnStart", try connecting right away
928
     */
929
    private async tryProfilingConnectOnStart() {
930
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
8✔
931
            try {
2✔
932
                await this.perfettoManager.startTracing();
2✔
933
            } catch (e) {
934
                this.logger.error('Failed to start perfetto tracing on start', e);
1✔
935
            }
936
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
6✔
937
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
1!
938
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1✔
939
            this.logger.warn(message);
1✔
940
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
1✔
941
        }
942
    }
943

944
    /**
945
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
946
     */
947
    private resetSessionState() {
948
        // launchRequest gets invoked by our restart session flow.
949
        // We need to clear/reset some state to avoid issues.
950
        this.entryBreakpointWasHandled = false;
8✔
951
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
952
        //re-stages the project
953
        this.breakpointManager.reset();
8✔
954
    }
955

956
    /**
957
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
958
     */
959
    public async initRendezvousTracking() {
960
        const timeout = 5000;
3✔
961
        let initCompleted = false;
3✔
962
        await Promise.race([
3✔
963
            util.sleep(timeout),
964
            this._initRendezvousTracking().finally(() => {
965
                initCompleted = true;
3✔
966
            })
967
        ]);
968

969
        if (initCompleted === false) {
3!
970
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
×
971
                this.logger.error('Error showing popup message', { error });
×
972
            });
973
        }
974
    }
975

976
    private async _initRendezvousTracking() {
977
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
978

979
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
980
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
3✔
981
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
982
        });
983

984
        // Send rendezvous events to the debug protocol client
985
        this.rendezvousTracker.on('rendezvous', (output) => {
3✔
986
            this.sendEvent(new RendezvousEvent(output));
1✔
987
        });
988

989
        //clear the history so the user doesn't have leftover rendezvous data from a previous session
990
        this.rendezvousTracker.clearHistory();
3✔
991

992
        //if rendezvous tracking is enabled, then enable it on the device
993
        if (this.launchConfiguration.rendezvousTracking !== false) {
3✔
994
            // start ECP rendezvous tracking (if possible)
995
            await this.rendezvousTracker.activate();
2✔
996
        }
997
    }
998

999
    /**
1000
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1001
     */
1002
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1003
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1004
        for (let diagnostic of diagnostics) {
2✔
1005
            diagnostic.source = diagnosticSource;
2✔
1006
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2✔
1007
            if (sourceLocation) {
2✔
1008
                diagnostic.path = sourceLocation.filePath;
1✔
1009
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1010
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1011
            } else {
1012
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1013
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1✔
1014
            }
1015
        }
1016

1017
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1018
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
2✔
1019
        if (this.compileError) {
2✔
1020
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
1021
            this.sendEvent(new StoppedEvent(
1✔
1022
                StoppedEventReason.exception,
1023
                this.COMPILE_ERROR_THREAD_ID,
1024
                `CompileError: ${this.compileError.message}`
1025
            ));
1026
        }
1027

1028
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2✔
1029
    }
1030

1031
    private publishTimeout = 60_000;
200✔
1032

1033
    private async publish() {
1034
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
2✔
1035
        let packageIsPublished = false;
2✔
1036

1037
        //delete any currently installed dev channel (if enabled to do so)
1038
        try {
2✔
1039
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
2!
1040
                await this.rokuDeploy.deleteInstalledChannel({
×
1041
                    ...this.launchConfiguration
1042
                } as any as RokuDeployOptions);
1043
            }
1044
        } catch (e) {
1045
            const statusCode = e?.results?.response?.statusCode;
×
1046
            const message = e.message as string;
×
1047
            if (statusCode === 401) {
×
1048
                await this.shutdown(message, true);
×
1049
                throw e;
×
1050
            }
1051
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1052
        }
1053

1054
        const isConnected = this.rokuAdapter.once('app-ready');
2✔
1055
        const options: RokuDeployOptions = {
2✔
1056
            ...this.launchConfiguration,
1057
            //typing fix
1058
            logLevel: LogLevelPriority[this.logger.logLevel],
1059
            // enable the debug protocol if true
1060
            remoteDebug: this.enableDebugProtocol,
1061
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1062
            remoteDebugConnectEarly: false,
1063
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1064
            failOnCompileError: true,
1065
            //pass any upload form overrides the client may have configured
1066
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1067
        };
1068
        //if packagePath is specified, use that info instead of outDir and outFile
1069
        if (this.launchConfiguration.packagePath) {
2✔
1070
            options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1071
            options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1072
        }
1073

1074
        //publish the package to the target Roku
1075
        const publishPromise = this.rokuDeploy.publish(options).then(() => {
2✔
1076
            packageIsPublished = true;
2✔
1077
        }).catch(async (e) => {
1078
            const statusCode = e?.results?.response?.statusCode;
×
1079
            const message = e.message as string;
×
1080
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
×
1081
                await this.shutdown(message, true);
×
1082
                throw e;
×
1083
            }
1084
            this.logger.error(e);
×
1085
        });
1086

1087
        await publishPromise;
2✔
1088

1089
        uploadingEnd();
2✔
1090

1091
        //the channel has been deployed. Wait for the adapter to finish connecting.
1092
        //if it hasn't connected after 60 seconds, abort the launch.
1093
        let didTimeOut = false;
2✔
1094
        await Promise.race([
2✔
1095
            isConnected,
1096
            util.sleep(this.publishTimeout).then(() => {
1097
                didTimeOut = true;
2✔
1098
            })
1099
        ]);
1100
        this.logger.log('Finished racing promises');
2✔
1101
        if (didTimeOut) {
2✔
1102
            this.logger.warn('Timed out waiting for roku to connect');
1✔
1103
        }
1104
        //if the adapter is still not connected, then it will probably never connect. Abort.
1105
        if (packageIsPublished && !this.rokuAdapter.connected) {
2✔
1106
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1✔
1107
        }
1108
    }
1109

1110
    private pendingSendLogPromise = Promise.resolve();
200✔
1111

1112
    /**
1113
     * Send log output to the "client" (i.e. vscode)
1114
     * @param logOutput
1115
     */
1116
    private sendLogOutput(logOutput: string) {
1117
        if (this.isCrashed) {
38!
1118
            return Promise.resolve();
×
1119
        }
1120
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38✔
1121

1122
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38✔
1123
            logOutput = await this.convertBacktracePaths(logOutput);
38✔
1124

1125
            const lines = logOutput.split(/\r?\n/g);
38✔
1126
            for (let i = 0; i < lines.length; i++) {
38✔
1127
                let line = lines[i];
264✔
1128
                if (i < lines.length - 1) {
264✔
1129
                    line += '\n';
226✔
1130
                }
1131

1132
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
264✔
1133
                    let potentialPaths = this.getPotentialPkgPaths(line);
91✔
1134
                    for (let potentialPath of potentialPaths) {
91✔
1135
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
28✔
1136
                        if (originalLocation) {
28✔
1137
                            let replacement: string;
1138
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
26✔
1139
                            if (replacement !== originalLocation.filePath) {
26✔
1140
                                if (this.isWindowsPlatform) {
6✔
1141
                                    replacement = `vscode://file/${replacement}`;
3✔
1142
                                } else {
1143
                                    replacement = `file://${replacement}`;
3✔
1144
                                }
1145
                            }
1146
                            replacement += `:${originalLocation.lineNumber}`;
26✔
1147
                            if (potentialPath.columnNumber !== undefined) {
26✔
1148
                                replacement += `:${originalLocation.columnIndex + 1}`;
10✔
1149
                            }
1150

1151
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1152
                        }
1153
                    }
1154
                }
1155
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1156
                this.sendEvent(new LogOutputEvent(line));
264✔
1157
            }
1158
        });
1159
        return this.pendingSendLogPromise;
38✔
1160
    }
1161

1162
    /**
1163
     * Extracts potential package paths from a given line of text.
1164
     *
1165
     * This method uses a regular expression to find matches in the provided line
1166
     * and returns an array of objects containing details about each match.
1167
     *
1168
     * @param input - The line of text to search for potential package paths.
1169
     * @returns An array of objects, each containing:
1170
     *   - `fullMatch`: The full matched string.
1171
     *   - `path`: The extracted path from the match.
1172
     *   - `lineNumber`: The line number extracted from the match.
1173
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1174
     */
1175
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1176
        // https://regex101.com/r/ixpQiq/1
1177
        let matches = input.matchAll(/((?:\.\.\.|[A-Za-z_0-9]*pkg\:\/)[A-Za-z_0-9 \/\.]+\.[A-Za-z_0-9 \/]+)(?:(?:\:)(\d+)(?:\:(\d+))?|\((\d+)(?:\:(\d+))?\))/ig);
91✔
1178
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91✔
1179
        if (matches) {
91!
1180
            for (let match of matches) {
91✔
1181
                let fullMatch = match[0];
28✔
1182
                let path = match[1];
28✔
1183
                let lineNumber = parseInt(match[2] ?? match[4]);
28✔
1184
                let columnNumber = parseInt(match[3] ?? match[5]);
28✔
1185
                if (isNaN(columnNumber)) {
28✔
1186
                    columnNumber = undefined;
17✔
1187
                }
1188
                paths.push({
28✔
1189
                    fullMatch: fullMatch,
1190
                    path: path,
1191
                    lineNumber: lineNumber,
1192
                    columnNumber: columnNumber
1193
                });
1194
            }
1195
        }
1196
        return paths;
91✔
1197
    }
1198

1199
    /**
1200
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1201
     */
1202
    private async convertBacktracePaths(input: string) {
1203
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38✔
1204
            return input;
8✔
1205
        }
1206
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1207
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1208

1209
        // https://regex101.com/r/y1koaV/2
1210
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
30✔
1211
        let matches = [];
30✔
1212
        let match = deviceBacktraceObjectRegex.exec(input);
30✔
1213
        while (match) {
30✔
1214
            matches.push(match);
5✔
1215
            match = deviceBacktraceObjectRegex.exec(input);
5✔
1216
        }
1217

1218
        if (matches) {
30!
1219
            for (let match of matches) {
30✔
1220
                let fullMatch = match[0] as string;
5✔
1221
                let filePath = match[1] as string;
5✔
1222
                let fullLineNumber = match[2] as string;
5✔
1223
                let lineNumber = parseInt(match[3] as string);
5✔
1224
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
5✔
1225
                if (originalLocation) {
5✔
1226
                    let fileReplacement: string;
1227
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
4✔
1228
                    if (fileReplacement !== originalLocation.filePath) {
4✔
1229
                        if (this.isWindowsPlatform) {
2✔
1230
                            fileReplacement = `vscode://file/${fileReplacement}`;
1✔
1231
                        } else {
1232
                            fileReplacement = `file://${fileReplacement}`;
1✔
1233
                        }
1234
                    }
1235
                    fileReplacement += `:${originalLocation.lineNumber}`;
4✔
1236

1237
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1238

1239
                    // replace the full backtrace object with the an updated version so we don't modify other parts of the log output that might contain the same file path
1240
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1241
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1242
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1243
                }
1244

1245
            }
1246
        }
1247

1248
        return input;
30✔
1249
    }
1250

1251
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1252
        if (commands) {
1!
1253
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.host, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1254

1255
            try {
×
1256
                await connection.connect();
×
1257
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
×
1258
                    let response: SceneGraphCommandResponse;
1259
                    switch (command) {
×
1260
                        case 'chanperf':
1261
                            util.log('Enabling Chanperf Tracking');
×
1262
                            response = await connection.chanperf({ interval: 1 });
×
1263
                            if (!response.error) {
×
1264
                                util.log(response.result.rawResponse);
×
1265
                            }
1266
                            break;
×
1267

1268
                        case 'fpsdisplay':
1269
                            util.log('Enabling FPS Display');
×
1270
                            response = await connection.fpsDisplay('on');
×
1271
                            if (!response.error) {
×
1272
                                util.log(response.result.data as string);
×
1273
                            }
1274
                            break;
×
1275

1276
                        case 'logrendezvous':
1277
                            util.log('Enabling Rendezvous Logging:');
×
1278
                            response = await connection.logrendezvous('on');
×
1279
                            if (!response.error) {
×
1280
                                util.log(response.result.rawResponse);
×
1281
                            }
1282
                            break;
×
1283

1284
                        default:
1285
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
×
1286
                            response = await connection.exec(command);
×
1287
                            if (!response.error) {
×
1288
                                util.log(response.result.rawResponse);
×
1289
                            }
1290
                            break;
×
1291
                    }
1292
                }
1293
                await connection.end();
×
1294
            } catch (error) {
1295
                util.log(`Error connecting to port 8080: ${error.message}`);
×
1296
            }
1297
        }
1298
    }
1299

1300
    /**
1301
     * Stage, insert breakpoints, and package the main project
1302
     */
1303
    public async prepareMainProject() {
1304
        //add the main project
1305
        this.projectManager.mainProject = new Project({
2✔
1306
            rootDir: this.launchConfiguration.rootDir,
1307
            files: this.launchConfiguration.files,
1308
            outDir: this.launchConfiguration.outDir,
1309
            sourceDirs: this.launchConfiguration.sourceDirs,
1310
            bsConst: this.launchConfiguration.bsConst,
1311
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1312
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1313
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1314
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1315
            stagingDir: this.launchConfiguration.stagingDir,
1316
            packagePath: this.launchConfiguration.packagePath,
1317
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1318
        });
1319

1320
        util.log('Moving selected files to staging area');
2✔
1321
        await this.projectManager.mainProject.stage();
2✔
1322

1323
        //add the entry breakpoint if stopOnEntry is true
1324
        await this.handleEntryBreakpoint();
2✔
1325
    }
1326

1327
    /**
1328
     * Inject breakpoint STOP statements into the staged main project. Runs after the DAP `InitializedEvent`
1329
     * so client-side `setBreakpoints` requests have landed before any STOPs are written to the staged .brs
1330
     * files (telnet path). Kept separate from zipping so the cross-project `Library` reference rewrite can
1331
     * run in between (after every project is postfixed, before any zip is sealed).
1332
     */
1333
    private async writeMainProjectBreakpoints() {
1334
        //add breakpoint lines to source files and then publish
1335
        util.log('Adding stop statements for active breakpoints');
2✔
1336

1337
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1338
        await this.breakpointManager.validateAndWriteBreakpointsForProject(this.projectManager.mainProject);
2✔
1339
    }
1340

1341
    /**
1342
     * Create the main project's zip package (or run the configured packageTask). Must run AFTER
1343
     * `applyLibraryReferencePostfixes` so any rewritten `Library` statements are included in the zip.
1344
     */
1345
    private async zipMainProject() {
1346
        if (this.launchConfiguration.packageTask) {
2✔
1347
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1348
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1349

1350
            const options = {
1✔
1351
                ...this.launchConfiguration
1352
            } as any as RokuDeployOptions;
1353
            //if packagePath is specified, use that info instead of outDir and outFile
1354
            if (this.launchConfiguration.packagePath) {
1!
1355
                options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1356
                options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1357
            }
1358
            const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options);
1!
1359

1360
            if (!fsExtra.pathExistsSync(packagePath as string)) {
1!
1361
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
×
1362
            }
1363
        } else {
1364
            //create zip package from staging folder
1365
            util.log('Creating zip archive from project sources');
1✔
1366
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1✔
1367
        }
1368
    }
1369

1370
    /**
1371
     * Accepts custom events and requests from the extension
1372
     * @param command name of the command to execute
1373
     */
1374
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
1375
        if (command === 'rendezvous.clearHistory') {
×
1376
            this.rokuAdapter.clearRendezvousHistory();
×
1377
        } else if (command === 'chanperf.clearHistory') {
×
1378
            this.rokuAdapter.clearChanperfHistory();
×
1379

1380
        } else if (command === 'customRequestEventResponse') {
×
1381
            this.emit('customRequestEventResponse', args);
×
1382

1383
        } else if (command === 'popupMessageEventResponse') {
×
1384
            this.emit('popupMessageEventResponse', args);
×
1385

1386
        } else if (command === 'captureHeapSnapshot') {
×
1387
            this.perfettoManager.captureHeapSnapshot().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
×
1388

1389
        } else if (command === 'startPerfettoTracing') {
×
1390
            try {
×
1391
                await this.perfettoManager.startTracing();
×
1392
            } catch (e) {
1393
                response.success = false;
×
1394
                response.body = { message: e?.message || String(e) };
×
1395
            }
1396

1397
        } else if (command === 'stopPerfettoTracing') {
×
1398
            try {
×
1399
                await this.perfettoManager.stopTracing();
×
1400
            } catch (e) {
1401
                response.success = false;
×
1402
                response.body = { message: e?.message || String(e) };
×
1403
            }
1404

1405
        }
1406
        this.sendResponse(response);
×
1407
    }
1408

1409
    /**
1410
     * Stores the path to the staging folder for each component library
1411
     */
1412
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1413
        if (!componentLibraries || componentLibraries.length === 0) {
11✔
1414
            return;
2✔
1415
        }
1416
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1417
        //make sure this folder exists (and is empty)
1418
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1419
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1420

1421
        //create a ComponentLibraryProject for each component library
1422
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9✔
1423
            let componentLibrary = componentLibraries[libraryIndex];
19✔
1424

1425
            this.projectManager.componentLibraryProjects.push(
19✔
1426
                new ComponentLibraryProject({
1427
                    rootDir: componentLibrary.rootDir,
1428
                    files: componentLibrary.files,
1429
                    outDir: componentLibrariesOutDir,
1430
                    outFile: componentLibrary.outFile,
1431
                    sourceDirs: componentLibrary.sourceDirs,
1432
                    bsConst: componentLibrary.bsConst,
1433
                    install: componentLibrary.install,
1434
                    enablePostfix: componentLibrary.enablePostfix,
1435
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1436
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1437
                    libraryIndex: libraryIndex,
1438
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1439
                })
1440
            );
1441
        }
1442

1443
        //stage all of the libraries in parallel
1444
        await Promise.all(
9✔
1445
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
1446
        );
1447
    }
1448

1449
    /**
1450
     * Inject breakpoint STOPs into the staged complibs and postfix each library's own files. Runs in
1451
     * `configurationDoneRequest` (after `InitializedEvent`) so client-side `setBreakpoints` requests have
1452
     * landed before the staged .brs files are written. Kept separate from zipping so the cross-project
1453
     * `Library` reference rewrite can run after EVERY project is postfixed but before any zip is sealed.
1454
     */
1455
    protected async writeAndPostfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1456
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1457
            return;
2✔
1458
        }
1459

1460
        // Add breakpoint lines to the staging files and before publishing
1461
        util.log('Adding stop statements for active breakpoints in Component Libraries');
8✔
1462

1463
        //validate breakpoints (and write STOPs for telnet) and postfix each complib's own files in parallel
1464
        await Promise.all(
8✔
1465
            this.projectManager.componentLibraryProjects.map(async (compLibProject) => {
1466
                await this.breakpointManager.validateAndWriteBreakpointsForProject(compLibProject);
18✔
1467
                await compLibProject.postfixFiles();
18✔
1468
            })
1469
        );
1470
    }
1471

1472
    /**
1473
     * Seal every component library's zip, install the installable ones (in order), and start static file
1474
     * hosting. Must run AFTER `applyLibraryReferencePostfixes` so each zip contains the rewritten `Library`
1475
     * statements.
1476
     */
1477
    protected async zipAndHostComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1478
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1479
            return;
2✔
1480
        }
1481
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8✔
1482

1483
        //seal every complib's zip in parallel — each targets distinct files
1484
        const packagePromises = this.projectManager.componentLibraryProjects.map(
8✔
1485
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
18✔
1486
        );
1487

1488
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9✔
1489
        if (needToDeleteComplibs) {
8✔
1490
            await rokuDeploy.deleteAllComponentLibraries({
7✔
1491
                host: this.launchConfiguration.host,
1492
                password: this.launchConfiguration.password,
1493
                username: this.launchConfiguration.username || 'rokudev'
14✔
1494
            });
1495
        }
1496

1497
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
8✔
1498
            const compLibProject = this.projectManager.componentLibraryProjects[i];
18✔
1499

1500
            if (compLibProject.install === true) {
18✔
1501
                //wait for this complib to finish being packaged
1502
                await packagePromises[i];
12✔
1503

1504
                if (componentLibraries[i].packageTask) {
12✔
1505
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1506
                }
1507

1508
                const options: RokuDeployOptions = {
12✔
1509
                    host: this.launchConfiguration.host,
1510
                    password: this.launchConfiguration.password,
1511
                    username: this.launchConfiguration.username || 'rokudev',
24✔
1512
                    logLevel: LogLevelPriority[this.logger.logLevel],
1513
                    failOnCompileError: true,
1514
                    outDir: compLibProject.outDir,
1515
                    outFile: compLibProject.outFile,
1516
                    appType: 'dcl',
1517
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
22✔
1518
                };
1519

1520
                if (componentLibraries[i].packagePath) {
12✔
1521
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
2✔
1522
                    options.outFile = path.basename(componentLibraries[i].packagePath);
2✔
1523
                }
1524

1525
                try {
12✔
1526
                    await rokuDeploy.publish(options);
12✔
1527
                } catch (error) {
1528
                    this.logger.error(`Error installing component library ${i}`, error);
4✔
1529
                }
1530
            }
1531
        }
1532

1533
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
8✔
NEW
1534
            util.log(message);
×
1535
        });
1536

1537
        //wait for all complib packaging to finish and the file hosting to start
1538
        await Promise.all([
8✔
1539
            ...packagePromises,
1540
            hostingPromise
1541
        ]);
1542
    }
1543

1544
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
UNCOV
1545
        this.logger.log('sourceRequest');
×
UNCOV
1546
        let old = this.sendResponse;
×
UNCOV
1547
        this.sendResponse = function sendResponse(...args) {
×
UNCOV
1548
            old.apply(this, args);
×
UNCOV
1549
            this.sendResponse = old;
×
1550
        };
UNCOV
1551
        super.sourceRequest(response, args);
×
1552
    }
1553

1554
    /**
1555
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1556
     */
1557
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1558
        this.logger.log('setBreakpointsRequest', args);
6✔
1559
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6✔
1560
        //sort the breakpoints
1561
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6✔
1562

1563
        response.body = {
6✔
1564
            breakpoints: sortedAndFilteredBreakpoints
1565
        };
1566
        this.sendResponse(response);
6✔
1567

1568
        //ensure we've staged all the files
1569
        await this.stagingDefered.promise;
6✔
1570

1571
        await this.rokuAdapter?.syncBreakpoints();
6!
1572
    }
1573

1574
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
NEW
1575
        this.logger.log('exceptionInfoRequest');
×
1576
    }
1577

1578
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1579
        this.logger.log('threadsRequest');
4✔
1580

1581
        let threads = [];
4✔
1582

1583
        //This is a bit of a hack. If there's a compile error, send a thread to represent it so we can show the compile error like a runtime exception
1584
        if (this.compileError) {
4!
NEW
1585
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1586
        } else {
1587
            //wait for the roku adapter to load
1588
            await this.getRokuAdapter();
4✔
1589

1590
            //only send the threads request if we are at the debugger prompt
1591
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1592
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1593

1594
                for (let thread of rokuThreads) {
3✔
1595
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1596
                    threads.push(
4✔
1597
                        new Thread(thread.threadId, threadName)
1598
                    );
1599
                }
1600

1601
                if (threads.length === 0) {
3!
NEW
1602
                    threads = [{
×
1603
                        id: 1001,
1604
                        name: 'unable to retrieve threads: not stopped',
1605
                        isFake: true
1606
                    }];
1607
                }
1608

1609
            } else {
1610
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1611
            }
1612

1613
        }
1614

1615
        response.body = {
4✔
1616
            threads: threads
1617
        };
1618

1619
        this.sendResponse(response);
4✔
1620
    }
1621

1622
    /**
1623
     * Get the thread name to display in the UI based on the thread info we have.
1624
     * This is what displays in the `call stack` region in vscode
1625
     * @param thread
1626
     * @returns
1627
     */
1628
    private getThreadName(thread: AdapterThread) {
1629
        let threadName = '';
24✔
1630
        if (thread.type || thread.name || thread.osThreadId) {
24✔
1631
            //build the name from only the parts that are present, so missing values don't leak into the name
1632
            const parts: string[] = [];
13✔
1633
            if (thread.type) {
13✔
1634
                parts.push(`[${thread.type}]`);
10✔
1635
            }
1636
            if (thread.name) {
13✔
1637
                parts.push(thread.name);
9✔
1638
            }
1639
            if (thread.osThreadId) {
13✔
1640
                parts.push(thread.osThreadId);
9✔
1641
            }
1642
            threadName = parts.join(' ');
13✔
1643
        }
1644
        //remove any extraneous whitespace to deal with missing values
1645
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1646

1647
        if (threadName === '') {
24✔
1648
            threadName = `Thread ${thread.threadId}`;
11✔
1649
        }
1650

1651
        if (thread.isDetached) {
24✔
1652
            threadName += ' [detached]';
4✔
1653
        }
1654

1655
        //remove any extraneous whitespace to deal with missing values
1656
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1657

1658
        return threadName;
24✔
1659
    }
1660

1661
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1662
        try {
3✔
1663
            this.logger.log('stackTraceRequest');
3✔
1664
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1665

1666
            //this is a bit of a hack. If there's a compile error, send a full stack frame so we can show the compile error like a runtime crash
1667
            if (this.compileError) {
3!
1668
                frames.push(new StackFrame(
×
1669
                    0,
1670
                    'Compile Error',
1671
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1672
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1673
                    this.toClientLine(this.compileError.range.start.line),
1674
                    this.toClientColumn(this.compileError.range.start.character)
1675
                ));
1676
            } else if (args.threadId === 1001) {
3!
UNCOV
1677
                frames.push(new StackFrame(
×
1678
                    0,
1679
                    'ERROR: threads would not stop',
1680
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1681
                    this.toClientLine(0),
1682
                    this.toClientColumn(0)
1683
                ));
UNCOV
1684
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
UNCOV
1685
                    this.logger.error('Error showing popup message', { error });
×
1686
                });
1687
            } else {
1688
                //ensure the rokuAdapter is loaded
1689
                await this.getRokuAdapter();
3✔
1690

1691
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1692
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1693
                    if (stackTrace.length === 0) {
3✔
1694
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1695
                        // the thread without letting the user navigate to a source location
1696
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1697
                        frame.presentationHint = 'label';
2✔
1698
                        frames.push(frame);
2✔
1699
                    } else {
1700
                        for (let debugFrame of stackTrace) {
1✔
1701
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
1702

1703
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1704
                            //load the contents of the file and get the correct casing for the function identifier
1705
                            try {
3✔
1706
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1707
                                if (functionName) {
3!
1708

1709
                                    //search for original function name if this is an anonymous function.
1710
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1711
                                    if (functionName.startsWith('$')) {
3!
UNCOV
1712
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1713
                                            sourceLocation.filePath,
1714
                                            sourceLocation.lineNumber - 1,
1715
                                            functionName
1716
                                        );
1717
                                    }
1718
                                    debugFrame.functionIdentifier = functionName;
3✔
1719
                                }
1720
                            } catch (error) {
UNCOV
1721
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1722
                            }
1723
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3!
1724

1725
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1726
                                debugFrame.frameId,
1727
                                `${debugFrame.functionIdentifier}`,
1728
                                new Source(path.basename(filePath), filePath),
1729
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1730
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18!
1731
                                this.toClientColumn(0)
1732
                            );
1733
                            if (!sourceLocation) {
3!
UNCOV
1734
                                frame.presentationHint = 'subtle';
×
1735
                            }
1736
                            frames.push(frame);
3✔
1737
                        }
1738
                    }
1739
                } else {
UNCOV
1740
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1741
                }
1742
            }
1743
            response.body = {
3✔
1744
                stackFrames: frames,
1745
                totalFrames: frames.length
1746
            };
1747
            this.sendResponse(response);
3✔
1748
        } catch (error) {
UNCOV
1749
            this.logger.error('Error getting stacktrace', { error, args });
×
1750
        }
1751
    }
1752

1753
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1754
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1✔
1755
        logger.info('begin', { args });
1✔
1756
        try {
1✔
1757
            const scopes = new Array<DebugProtocol.Scope>();
1✔
1758

1759
            // create the locals scope
1760
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1761

1762
            let localScope: DebugProtocol.Scope = {
1✔
1763
                name: 'Local',
1764
                variablesReference: v.variablesReference,
1765
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1766
                expensive: this.launchConfiguration.deferScopeLoading,
1767
                presentationHint: 'locals'
1768
            };
1769

1770
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
UNCOV
1771
            if (frame) {
×
UNCOV
1772
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
1773

UNCOV
1774
                if (scopeRange) {
×
UNCOV
1775
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
UNCOV
1776
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
UNCOV
1777
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
UNCOV
1778
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1779
                }
1780
            }
1781

UNCOV
1782
            scopes.push(localScope);
×
1783

1784
            // create the registry scope
1785
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
UNCOV
1786
            scopes.push(<DebugProtocol.Scope>{
×
1787
                name: 'Registry',
1788
                variablesReference: registryRefId,
1789
                expensive: true
1790
            });
1791

UNCOV
1792
            this.variables[registryRefId] = {
×
1793
                variablesReference: registryRefId,
1794
                name: 'Registry',
1795
                value: '',
1796
                type: '$$Registry',
1797
                isScope: true,
1798
                childVariables: []
1799
            };
1800

1801
            response.body = {
×
1802
                scopes: scopes
1803
            };
UNCOV
1804
            logger.debug('send response', { response });
×
UNCOV
1805
            this.sendResponse(response);
×
UNCOV
1806
            logger.info('end');
×
1807
        } catch (error) {
1808
            logger.error('Error getting scopes', { error, args });
1✔
1809
        }
1810
    }
1811

1812
    /**
1813
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
1814
     * The child variables are filled in lazily by `populateScopeVariables`.
1815
     */
1816
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
1817
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
1818
        if (!this.variables[refId]) {
5✔
1819
            this.variables[refId] = {
1✔
1820
                variablesReference: refId,
1821
                name: 'Locals',
1822
                value: '',
1823
                type: '$$Locals',
1824
                frameId: frameId,
1825
                isScope: true,
1826
                childVariables: []
1827
            };
1828
        }
1829
        return this.variables[refId];
5✔
1830
    }
1831

1832
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
1833
        //if we have a compile error, we should shut down
UNCOV
1834
        if (this.compileError) {
×
UNCOV
1835
            this.sendResponse(response);
×
UNCOV
1836
            await this.shutdown();
×
UNCOV
1837
            return;
×
1838
        }
1839

UNCOV
1840
        this.logger.log('continueRequest');
×
UNCOV
1841
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
1842
        this.clearState();
×
1843

1844
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
1845
        await this.rokuAdapter.continue();
×
UNCOV
1846
        this.sendResponse(response);
×
1847
    }
1848

1849
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
UNCOV
1850
        this.logger.log('pauseRequest');
×
1851

1852
        //if we have a compile error, we should shut down
UNCOV
1853
        if (this.compileError) {
×
UNCOV
1854
            this.sendResponse(response);
×
UNCOV
1855
            await this.shutdown();
×
UNCOV
1856
            return;
×
1857
        }
1858

UNCOV
1859
        await this.rokuAdapter.pause();
×
UNCOV
1860
        this.sendResponse(response);
×
1861
    }
1862

1863
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
UNCOV
1864
        this.logger.log('reverseContinueRequest');
×
UNCOV
1865
        this.sendResponse(response);
×
1866
    }
1867

1868
    /**
1869
     * Clicked the "Step Over" button
1870
     * @param response
1871
     * @param args
1872
     */
1873
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
UNCOV
1874
        this.logger.log('[nextRequest] begin');
×
1875

1876
        //if we have a compile error, we should shut down
UNCOV
1877
        if (this.compileError) {
×
UNCOV
1878
            this.sendResponse(response);
×
UNCOV
1879
            await this.shutdown();
×
UNCOV
1880
            return;
×
1881
        }
1882

UNCOV
1883
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
1884
        this.clearState();
×
1885

1886
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
1887
        try {
×
1888
            await this.rokuAdapter.stepOver(args.threadId);
×
1889
            this.logger.info('[nextRequest] end');
×
1890
        } catch (error) {
1891
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
1892
        }
1893
        this.sendResponse(response);
×
1894
    }
1895

1896
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
UNCOV
1897
        this.logger.log('[stepInRequest]');
×
1898

1899
        //if we have a compile error, we should shut down
UNCOV
1900
        if (this.compileError) {
×
UNCOV
1901
            this.sendResponse(response);
×
1902
            await this.shutdown();
×
1903
            return;
×
1904
        }
1905

UNCOV
1906
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
1907
        this.clearState();
×
1908
        // The debug session ends after the next line. Do not put new work after this line.
1909
        await this.rokuAdapter.stepInto(args.threadId);
×
UNCOV
1910
        this.sendResponse(response);
×
UNCOV
1911
        this.logger.info('[stepInRequest] end');
×
1912
    }
1913

1914
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
UNCOV
1915
        this.logger.log('[stepOutRequest] begin');
×
1916

1917
        //if we have a compile error, we should shut down
1918
        if (this.compileError) {
×
UNCOV
1919
            this.sendResponse(response);
×
UNCOV
1920
            await this.shutdown();
×
1921
            return;
×
1922
        }
1923

UNCOV
1924
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
1925
        this.clearState();
×
1926

1927
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
1928
        await this.rokuAdapter.stepOut(args.threadId);
×
UNCOV
1929
        this.sendResponse(response);
×
UNCOV
1930
        this.logger.info('[stepOutRequest] end');
×
1931
    }
1932

1933
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
UNCOV
1934
        this.logger.log('[stepBackRequest] begin');
×
UNCOV
1935
        this.sendResponse(response);
×
UNCOV
1936
        this.logger.info('[stepBackRequest] end');
×
1937
    }
1938

1939
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
1940
        const logger = this.logger.createLogger('[variablesRequest]');
4✔
1941
        let sendInvalidatedEvent = false;
4✔
1942
        let frameId: number = null;
4✔
1943
        try {
4✔
1944
            logger.log('begin', { args });
4✔
1945

1946
            //ensure the rokuAdapter is loaded
1947
            await this.getRokuAdapter();
4✔
1948

1949
            let updatedVariables: AugmentedVariable[] = [];
4✔
1950
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
1951
            await this.evaluateRequestPromise;
4✔
1952
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
1953
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
1954
                response.success = false;
×
UNCOV
1955
                response.message = 'Debug session is not paused';
×
UNCOV
1956
                return this.sendResponse(response);
×
1957
            }
1958

1959
            //find the variable with this reference
1960
            let v = this.variables[args.variablesReference];
4✔
1961
            if (!v) {
4!
1962
                response.success = false;
×
1963
                response.message = `Variable reference has expired`;
×
UNCOV
1964
                return this.sendResponse(response);
×
1965
            }
1966
            logger.log('variable', v);
4✔
1967

1968
            // Populate scope level values if needed
1969
            if (v.isScope) {
4✔
1970
                await this.populateScopeVariables(v, args);
2✔
1971
            }
1972

1973
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
1974
            if (v.childVariables.length === 0 || v.isResolved) {
4!
1975
                let tempVar: AugmentedVariable;
1976
                if (!v.isResolved) {
×
1977
                    // Evaluate the variable
UNCOV
1978
                    try {
×
UNCOV
1979
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
UNCOV
1980
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
1981
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
1982
                        tempVar.frameId = v.frameId;
×
1983
                        // Determine if the variable has changed
UNCOV
1984
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
1985
                    } catch (error) {
UNCOV
1986
                        logger.error('Error getting variables', error);
×
UNCOV
1987
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
UNCOV
1988
                        tempVar.type = '';
×
UNCOV
1989
                        tempVar.childVariables = [];
×
UNCOV
1990
                        sendInvalidatedEvent = true;
×
1991
                        response.success = false;
×
UNCOV
1992
                        response.message = error.message;
×
1993
                    }
1994

1995
                    // Merge the resulting updates together
1996
                    v.childVariables = tempVar.childVariables;
×
1997
                    v.value = tempVar.value;
×
UNCOV
1998
                    v.type = tempVar.type;
×
UNCOV
1999
                    v.indexedVariables = tempVar.indexedVariables;
×
2000
                    v.namedVariables = tempVar.namedVariables;
×
2001
                }
UNCOV
2002
                frameId = v.frameId;
×
2003

2004
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2005
                    // If this was a lazy variable we need to respond with the updated variable and not the children
2006
                    if (v.isResolved && v.childVariables.length > 0) {
×
UNCOV
2007
                        updatedVariables = v.childVariables;
×
2008
                    } else {
UNCOV
2009
                        updatedVariables = [v];
×
2010
                    }
UNCOV
2011
                    v.isResolved = true;
×
2012
                } else {
UNCOV
2013
                    updatedVariables = v.childVariables;
×
2014
                }
2015

2016
                // If the variable has no children, set the reference to 0
2017
                // so it does not look expandable in the Ui
2018
                if (v.childVariables.length === 0) {
×
2019
                    v.variablesReference = 0;
×
2020
                }
2021

2022
                // If the variable was resolve in the past we may not have fetched a new temp var
2023
                tempVar ??= v;
×
2024
                if (v?.presentationHint) {
×
UNCOV
2025
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2026
                } else {
2027
                    v.presentationHint = tempVar.presentationHint;
×
2028
                }
2029

2030
            } else {
2031
                updatedVariables = v.childVariables;
4✔
2032
            }
2033

2034
            // Only send the updated variables if we are not going to trigger an invalidated event.
2035
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2036
            response.body = {
4✔
2037
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2038
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2039
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2040
            };
2041
        } catch (error) {
2042
            logger.error('Error during variablesRequest', error, { args });
×
UNCOV
2043
            response.success = false;
×
UNCOV
2044
            response.message = error?.message ?? 'Error during variablesRequest';
×
2045
        } finally {
2046
            logger.info('end', { response });
4✔
2047
        }
2048
        this.sendResponse(response);
4✔
2049
        if (sendInvalidatedEvent) {
4!
UNCOV
2050
            this.debounceSendInvalidatedEvent(null, frameId);
×
2051
        }
2052
    }
2053

2054
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
200✔
UNCOV
2055
        this.sendInvalidatedEvent(threadId, frameId);
×
2056
    }, 50);
2057

2058

2059
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2060
        if (!updatedVariables || !v) {
4!
UNCOV
2061
            return [];
×
2062
        }
2063

2064
        let start = args.start ?? 0;
4!
2065

2066
        //if the variable is an array, send only the requested range
2067
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4!
2068
            //only send the variable range requested by the debugger
UNCOV
2069
            if (!args.count) {
×
2070
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2071
            } else {
2072
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2073
            }
2074
        }
2075

2076
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4!
2077
            // We currently do not support named variable paging so we always send all named variables
2078
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2079
        }
2080

2081
        let filteredUpdatedVariables = this.launchConfiguration.showHiddenVariables !== true ? updatedVariables.filter(
4✔
2082
            (child: AugmentedVariable) => !child.name.startsWith(this.tempVarPrefix)) : updatedVariables;
6✔
2083

2084
        if (this.launchConfiguration.showHiddenVariables !== true) {
4✔
2085
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2✔
2086
                //A transient variable that we show when there is a value
2087
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4!
UNCOV
2088
                    return true;
×
2089
                } else if (util.isTransientVariable(child.name)) {
4!
UNCOV
2090
                    return false;
×
2091
                } else {
2092
                    return true;
4✔
2093
                }
2094
            });
2095
        }
2096

2097
        return filteredUpdatedVariables;
4✔
2098
    }
2099

2100
    /**
2101
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2102
     * @param v scope variable to populate
2103
     * @param args
2104
     */
2105
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2106
        if (v.childVariables.length > 0) {
4✔
2107
            // Already populated
2108
            return;
3✔
2109
        }
2110

2111
        let tempVar: AugmentedVariable;
2112
        try {
1✔
2113
            if (v.type === '$$Locals') {
1!
2114
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1!
2115
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1✔
2116
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1✔
2117
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2118
                    // NOTE: Legacy telnet support
2119
                    let variables: AugmentedVariable[] = [];
×
UNCOV
2120
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2121

2122
                    // Fetch each variable individually
2123
                    for (const varName of varNames) {
×
2124
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
UNCOV
2125
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
2126
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
UNCOV
2127
                        variables.push(tempLocalsVar);
×
2128
                    }
UNCOV
2129
                    tempVar = {
×
2130
                        ...v,
2131
                        childVariables: variables,
2132
                        namedVariables: variables.length,
2133
                        indexedVariables: 0
2134
                    };
2135
                }
2136

2137
                // Merge the resulting updates together onto the original variable
2138
                v.childVariables = tempVar.childVariables;
1✔
2139
                v.namedVariables = tempVar.namedVariables;
1✔
2140
                v.indexedVariables = tempVar.indexedVariables;
1✔
2141
            } else if (v.type === '$$Registry') {
×
2142
                // This is a special scope variable used to load registry data via an ECP call
2143
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
2144
                await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2145
            }
2146
        } catch (error) {
UNCOV
2147
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
UNCOV
2148
            tempVar = {
×
2149
                name: '',
2150
                value: `❌ Error: ${error.message}`,
2151
                variablesReference: 0,
2152
                childVariables: []
2153
            };
UNCOV
2154
            v.childVariables = [tempVar];
×
UNCOV
2155
            v.namedVariables = 1;
×
UNCOV
2156
            v.indexedVariables = 0;
×
2157
        }
2158

2159
        // Mark the scope as resolved so we don't re-fetch the variables
2160
        v.isResolved = true;
1✔
2161

2162
        // If the scope has no children, add a single child to indicate there are no values
2163
        if (v.childVariables.length === 0) {
1!
UNCOV
2164
            tempVar = {
×
2165
                name: '',
2166
                value: `No values for scope '${v.name}'`,
2167
                variablesReference: 0,
2168
                childVariables: []
2169
            };
UNCOV
2170
            v.childVariables = [tempVar];
×
UNCOV
2171
            v.namedVariables = 1;
×
2172
            v.indexedVariables = 0;
×
2173
        }
2174
    }
2175

2176
    private evaluateRequestPromise = Promise.resolve();
200✔
2177
    private evaluateVarIndexByFrameId = new Map<number, number>();
200✔
2178

2179
    private getNextVarIndex(frameId: number): number {
2180
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6✔
2181
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5✔
2182
        }
2183
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6✔
2184
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6✔
2185
        return value;
6✔
2186
    }
2187

2188
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2189
        //ensure the rokuAdapter is loaded
2190
        await this.getRokuAdapter();
15✔
2191

2192
        let deferred = defer<void>();
15✔
2193
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15!
UNCOV
2194
            this.clearState();
×
UNCOV
2195
            this.rokuAdapter.clearCache();
×
UNCOV
2196
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
UNCOV
2197
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
UNCOV
2198
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
UNCOV
2199
            this.sendResponse(response);
×
UNCOV
2200
            return deferred.promise;
×
2201
        }
2202

2203
        try {
15✔
2204
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2205
                return deferred.promise;
15✔
2206
            });
2207

2208
            //fix vscode hover bug that excludes closing quotemark sometimes.
2209
            if (args.context === 'hover') {
15✔
2210
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2211
            }
2212

2213
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15✔
2214
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1✔
2215
                if (args.context === 'repl') {
1!
2216
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1✔
2217
                    response.body = {
1✔
2218
                        result: 'invalid',
2219
                        variablesReference: 0
2220
                    };
2221
                } else {
UNCOV
2222
                    throw new Error(message);
×
2223
                }
2224

2225
                //is at debugger prompt
2226
            } else if (args.expression.trim()) {
14!
2227
                // We trim and check that the expression is not an empty string so that we do not send empty expressions to the Roku
2228
                // This happens mostly when hovering over leading whitespace in the editor
2229

2230
                let { evalArgs, variablePath } = await this.evaluateExpressionToTempVar(args, util.getVariablePath(args.expression));
14✔
2231

2232
                //if we found a variable path (e.g. ['a', 'b', 'c']) then do a variable lookup because it's faster and more widely supported than `evaluate`
2233
                if (variablePath) {
14✔
2234
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2235
                    let v: AugmentedVariable;
2236
                    //if we already looked this item up, return it
2237
                    if (this.variables[refId]) {
11✔
2238
                        v = this.variables[refId];
1✔
2239
                    } else {
2240
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2241
                        if (!result) {
10!
2242
                            throw new Error('Error: unable to evaluate expression');
×
2243
                        }
2244

2245
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10✔
2246
                        //TODO - testing something, remove later
2247
                        // eslint-disable-next-line camelcase
2248
                        v.request_seq = response.request_seq;
10✔
2249
                        v.frameId = evalArgs.frameId;
10✔
2250
                    }
2251
                    response.body = {
11✔
2252
                        result: v.value,
2253
                        type: v.type,
2254
                        variablesReference: v.variablesReference,
2255
                        namedVariables: v.namedVariables || 0,
21✔
2256
                        indexedVariables: v.indexedVariables || 0
21✔
2257
                    };
2258

2259
                    //run an `evaluate` call
2260
                } else {
2261
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2262

2263
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2264
                    if (args.context === 'repl') {
3!
2265
                        // Clear variable cache since this action could have side-effects
2266
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2267
                        this.clearState();
3✔
2268
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2269
                    }
2270

2271
                    // If the adapter captured output (probably only telnet), log the results
2272
                    if (typeof commandResults.message === 'string') {
3✔
2273
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2274
                        if (args.context === 'repl') {
1!
2275
                            // If the command was a repl command, send the output to the debug console for the developer as well
2276
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2277
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1!
2278
                        }
2279
                    }
2280

2281
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3✔
2282
                        response.body = {
2✔
2283
                            result: 'invalid',
2284
                            variablesReference: 0
2285
                        };
2286
                    } else {
2287
                        response.body = {
1✔
2288
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1!
2289
                            variablesReference: 0
2290
                        };
2291
                    }
2292
                }
2293
            }
2294
        } catch (error) {
UNCOV
2295
            this.logger.error('Error during variables request', error);
×
UNCOV
2296
            response.success = false;
×
UNCOV
2297
            response.message = error?.message ?? error;
×
2298
        }
2299
        try {
15✔
2300
            this.sendResponse(response);
15✔
2301
        } catch { }
2302
        deferred.resolve();
15✔
2303
    }
2304

2305
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2306
        let returnVal = { evalArgs: args, variablePath };
19✔
2307
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19✔
2308
            let varIndex = this.getNextVarIndex(args.frameId);
6✔
2309
            let arrayVarName = this.tempVarPrefix + 'eval';
6✔
2310
            let command = '';
6✔
2311
            if (varIndex === 0) {
6✔
2312
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5✔
2313
            }
2314
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6✔
2315
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6✔
2316
            command += statement;
6✔
2317
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6✔
2318
            if (commandResults.type === 'error') {
6!
UNCOV
2319
                throw new Error(commandResults.message);
×
2320
            }
2321
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6✔
2322
        }
2323
        return returnVal;
19✔
2324
    }
2325

2326
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
UNCOV
2327
        let results = {
×
2328
            evaluations: [],
2329
            bulkVarName: ''
2330
        };
UNCOV
2331
        let storedVariables = [];
×
UNCOV
2332
        let command = '';
×
UNCOV
2333
        for (let i = 0; i < argsArray.length; i++) {
×
UNCOV
2334
            let args = argsArray[i];
×
UNCOV
2335
            let variablePath = variablePathArray[i];
×
UNCOV
2336
            let returnVal = { evalArgs: args, variablePath };
×
UNCOV
2337
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
UNCOV
2338
                let varIndex = this.getNextVarIndex(frameId);
×
2339
                let arrayVarName = this.tempVarPrefix + 'eval';
×
UNCOV
2340
                if (varIndex === 0) {
×
UNCOV
2341
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2342
                }
UNCOV
2343
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
UNCOV
2344
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
UNCOV
2345
                command += statement;
×
2346

UNCOV
2347
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
UNCOV
2348
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2349
            }
2350

UNCOV
2351
            results.evaluations[i] = returnVal;
×
2352
        }
2353

UNCOV
2354
        if (command) {
×
2355

2356
            // create a bulk container for the command results
UNCOV
2357
            let varIndex = this.getNextVarIndex(frameId);
×
UNCOV
2358
            let arrayVarName = this.tempVarPrefix + 'eval';
×
2359
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
UNCOV
2360
            for (let storedVariable of storedVariables) {
×
UNCOV
2361
                bulkContainerStatement += `${storedVariable},\n`;
×
2362
            }
UNCOV
2363
            bulkContainerStatement += `]`;
×
2364

UNCOV
2365
            command += bulkContainerStatement;
×
2366

UNCOV
2367
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2368

UNCOV
2369
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
UNCOV
2370
            if (commandResults.type === 'error') {
×
UNCOV
2371
                throw new Error(commandResults.message);
×
2372
            }
2373
        }
2374

UNCOV
2375
        return results;
×
2376
    }
2377

2378
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2379
        this.logger.log('completionsRequest', args, request);
20✔
2380
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2381
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2382

2383
        try {
20✔
2384
            let supplyLocalScopeCompletions = false;
20✔
2385

2386
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2387

2388
            if (!closestCompletionDetails) {
20!
2389
                // If the cursor is not at the end of the line, then we should not supply completions at this time
UNCOV
2390
                response.body = {
×
2391
                    targets: []
2392
                };
UNCOV
2393
                return this.sendResponse(response);
×
2394
            }
2395
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20✔
2396

2397
            let parentVariablePath = closestCompletionDetails.parentVariablePath;
20✔
2398
            // When set, the user is typing a string key (ex: `m["fo`) and completions should insert the
2399
            // key wrapped to close the access (ex: `firstName"]`) rather than appending a bare label. The
2400
            // value is the closing text to append (empty when a closing bracket is already present).
2401
            const stringKeyClosing = closestCompletionDetails.stringKeyClosing;
20✔
2402

2403
            // The span of input each completion replaces. The client requests completions once (at the first
2404
            // character) and then filters the list as the user keeps typing, so without an explicit range that
2405
            // incremental filtering is anchored incorrectly.
2406
            const replaceRange = this.getCompletionReplaceRange(args);
20✔
2407
            // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
2408
            // member access). A key that can't be dot-accessed (ex: `my key`) is rewritten as bracket access,
2409
            // which has to consume that `.` so `m.` becomes `m["my key"]` rather than `m.["my key"]`.
2410
            const lines = args.text.split('\n');
20✔
2411
            const targetLine = lines[this.toDebuggerLine(args.line, 0)] ?? '';
20!
2412
            const precededByDot = targetLine[replaceRange.start - 1] === '.';
20✔
2413

2414
            // Get the completions if the variable path was valid
2415
            if (parentVariablePath) {
20!
2416

2417
                // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
2418
                if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
20✔
2419
                    supplyLocalScopeCompletions = true;
4✔
2420
                }
2421

2422
                // Look up the parent variable (in-memory first, then the device), scoped to the current frame.
2423
                let parentVariable = await this.resolveCompletionParentVariable(parentVariablePath, args.frameId);
20✔
2424

2425
                // provide completions for the parent variable if one was found
2426
                if (parentVariable) {
20✔
2427
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2428
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2429
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2430
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19✔
2431
                        parentVariable.type === VariableType.List ||
2432
                        parentVariable.type === 'roXMLList' ||
2433
                        parentVariable.type === 'roByteArray';
2434

2435
                    const possibleFieldsAndMethods = isIntegerIndexed
19✔
2436
                        ? []
2437
                        // Filter out virtual variables and the empty-named placeholder used for empty scopes
2438
                        : parentVariable.childVariables.filter((child) => child.name && child.presentationHint?.kind !== 'virtual');
21!
2439

2440
                    for (let v of possibleFieldsAndMethods) {
19✔
2441
                        // Default completion type should be variable
2442
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21✔
2443
                        if (!supplyLocalScopeCompletions) {
21✔
2444
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2445
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17!
2446
                                completionType = 'field';
17✔
2447
                            }
2448

2449
                            switch (v.type) {
17!
2450
                                case VariableType.Function:
2451
                                case VariableType.Subroutine:
2452
                                    completionType = 'method';
×
2453
                                    break;
×
2454
                                default:
2455
                                    break;
17✔
2456
                            }
2457
                        }
2458

2459
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2460
                            label: v.name,
2461
                            type: completionType,
2462
                            //rank a variable's own members/locals above everything else
2463
                            sortText: `${CompletionSortTier.Member}${v.name}`
2464
                        };
2465
                        if (stringKeyClosing !== undefined) {
21✔
2466
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2467
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2468
                            // (ex: a key of `a"b` is inserted as `a""b`).
2469
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2470
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
2471
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2472
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2473
                            // the key is escaped as `""` so the inserted string literal stays valid.
2474
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2475
                            completionItem.start = replaceRange.start - 1;
3✔
2476
                            completionItem.length = replaceRange.length + 1;
3✔
2477
                        }
2478
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2479
                    }
2480

2481
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2482
                    if (stringKeyClosing === undefined) {
19✔
2483
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2484
                        //assemble a list of all methods on the parent component
2485
                        const methods = [
15✔
2486
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2487
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2488
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2489
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2490
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2491
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
2492
                        ].flat();
2493

2494
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2495
                        for (const method of methods) {
15✔
2496
                            completions.set(`method-${method.name}`, {
185✔
2497
                                label: method.name,
2498
                                type: 'method',
2499
                                detail: method.description ?? '',
555!
2500
                                sortText: `${CompletionSortTier.Method}${method.name}`
2501
                            });
2502
                        }
2503
                    }
2504

2505
                    // Add the global functions to the completions results
2506
                    if (supplyLocalScopeCompletions) {
19✔
2507
                        for (let globalCallable of globalCallables) {
4✔
2508
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308✔
2509
                                label: globalCallable.name,
2510
                                type: 'function',
2511
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848!
2512
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2513
                            });
2514
                        }
2515

2516
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2517

2518
                        try {
4✔
2519
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4✔
2520
                            for (let scopeFunction of scopeFunctions) {
4✔
2521
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1!
2522
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2523
                                        label: scopeFunction.name,
2524
                                        type: scopeFunction.completionItemKind,
2525
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2526
                                    });
2527
                                }
2528
                            }
2529
                        } catch (e) {
UNCOV
2530
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2531
                        }
2532
                    }
2533
                }
2534
            }
2535

2536
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2537
            // rewrites above use an extended range that also consumes the preceding `.`).
2538
            for (const target of completions.values()) {
20✔
2539
                if (target.start === undefined) {
499✔
2540
                    target.start = replaceRange.start;
496✔
2541
                    target.length = replaceRange.length;
496✔
2542
                }
2543
            }
2544

2545
            response.body = {
20✔
2546
                targets: [...completions.values()]
2547
            };
2548
        } catch (error) {
2549
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2550
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
UNCOV
2551
            this.logger.error('Error during completionsRequest', error, { args });
×
2552
        }
2553
        this.sendResponse(response);
20✔
2554
    }
2555

2556
    /**
2557
     * Gets the closest completion details the incoming completion request.
2558
     */
2559
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2560
        const incomingText = args.text;
69✔
2561
        const lines = incomingText.split('\n');
69✔
2562
        let lineNumber = this.toDebuggerLine(args.line, 0);
69✔
2563
        let column = this.toDebuggerColumn(args.column);
69✔
2564

2565
        const targetLine = lines[lineNumber] ?? '';
69!
2566

2567
        const cursorIndex = column - 1;
69✔
2568
        const variableChars = /[a-z0-9_\.]/i;
69✔
2569

2570
        // If the character immediately to the right of the cursor is a variable character, then we are
2571
        // in the middle of a token and should not supply completions yet.
2572
        if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
69✔
2573
            return undefined;
2✔
2574
        }
2575

2576
        // Determine where the expression we want to complete ends, and whether we are completing the
2577
        // members of that expression. A trailing `.` or being inside an unclosed string-key bracket
2578
        // (ex: `m["fo`) are both treated as member access on the parent expression.
2579
        let endColumn = column;
67✔
2580
        let isMemberAccess = false;
67✔
2581
        //when set (including ''), the user is completing a string key; the value is the text to append to
2582
        //close the access (ex: `"]`), empty when a closing bracket is already present
2583
        let stringKeyClosing: string;
2584

2585
        const openBracket = this.findUnclosedOpener(targetLine, column);
67✔
2586
        if (openBracket?.char === '[') {
67✔
2587
            // find the opening quote (skipping any whitespace after the `[`)
2588
            let quoteIndex = openBracket.index + 1;
12✔
2589
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12✔
UNCOV
2590
                quoteIndex++;
×
2591
            }
2592
            const quote = targetLine[quoteIndex];
12✔
2593
            if (quote === '"' || quote === `'`) {
12✔
2594
                // The user is typing a string key, so complete the keys of the expression before the `[`
2595
                endColumn = openBracket.index;
8✔
2596
                isMemberAccess = true;
8✔
2597
                // close the string and bracket only when there is nothing meaningful after the cursor
2598
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8✔
2599
            }
2600
        }
2601

2602
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2603
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2604
        let startIndex = endColumn - 1;
67✔
2605
        let bracketDepth = 0;
67✔
2606
        while (startIndex >= 0) {
67✔
2607
            const char = targetLine[startIndex];
438✔
2608
            if (char === ']') {
438✔
2609
                bracketDepth++;
10✔
2610
            } else if (char === '[') {
428✔
2611
                if (bracketDepth === 0) {
12✔
2612
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2613
                    break;
2✔
2614
                }
2615
                bracketDepth--;
10✔
2616
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416✔
2617
                break;
23✔
2618
            }
2619
            startIndex--;
413✔
2620
        }
2621

2622
        const variablePathString = targetLine.slice(startIndex + 1, endColumn);
67✔
2623

2624
        // Attempted dot access on something unexpected.
2625
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2626
        // which leaves `.name` as the variable path string.
2627
        if (variablePathString.startsWith('.')) {
67✔
2628
            return undefined;
2✔
2629
        }
2630

2631
        if (variablePathString.endsWith('.')) {
65✔
2632
            isMemberAccess = true;
25✔
2633
        }
2634

2635
        // Get the variable path from the text
2636
        let variablePath: string[];
2637
        if (!variablePathString.trim()) {
65✔
2638
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2639
            variablePath = [''];
10✔
2640
        } else if (variablePathString.endsWith('.')) {
55✔
2641
            // supplied text ends with a period, so strip it off to create a valid variable path
2642
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25✔
2643
        } else {
2644
            variablePath = util.getVariablePath(variablePathString);
30✔
2645
        }
2646

2647
        // the target string is not a valid variable path
2648
        if (!variablePath) {
65✔
2649
            return undefined;
2✔
2650
        }
2651

2652
        // For member access we complete the members of the full expression. Otherwise we complete the
2653
        // siblings of the final (partial) segment, so drop it to get the parent.
2654
        let parentVariablePath = isMemberAccess ? variablePath : variablePath.slice(0, variablePath.length - 1);
63✔
2655

2656
        // An empty parent path means we are looking up the local scope variables and global functions
2657
        if (parentVariablePath.length === 0) {
63✔
2658
            parentVariablePath = [''];
17✔
2659
        }
2660

2661
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63✔
2662
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2663
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63✔
2664
            result.stringKeyClosing = stringKeyClosing;
8✔
2665
        }
2666
        return result;
63✔
2667
    }
2668

2669
    /**
2670
     * Compute the span of input text that a completion replaces: the run of identifier characters
2671
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2672
     * typing past the first character.
2673
     *
2674
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2675
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2676
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2677
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2678
     */
2679
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2680
        const lines = args.text.split('\n');
20✔
2681
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2682
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2683
        const targetLine = lines[lineNumber] ?? '';
20!
2684

2685
        const identifierChars = /[a-z0-9_]/i;
20✔
2686
        let wordStart = cursorOffset;
20✔
2687
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2688
            wordStart--;
7✔
2689
        }
2690
        return {
20✔
2691
            start: wordStart,
2692
            length: cursorOffset - wordStart
2693
        };
2694
    }
2695

2696
    /**
2697
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2698
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2699
     */
2700
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2701
        let depth = 0;
67✔
2702
        for (let i = column - 1; i >= 0; i--) {
67✔
2703
            const char = line[i];
604✔
2704
            if (char === ')' || char === ']' || char === '}') {
604✔
2705
                depth++;
12✔
2706
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2707
                if (depth === 0) {
34✔
2708
                    return { index: i, char: char };
22✔
2709
                }
2710
                depth--;
12✔
2711
            }
2712
        }
2713
        return undefined;
45✔
2714
    }
2715

2716
    /**
2717
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2718
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2719
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2720
     */
2721
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2722
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2723
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2724
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20✔
2725
        if (isLocalScope) {
20✔
2726
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2727
            if (!localsScope.isResolved) {
4!
2728
                try {
4✔
2729
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2730
                } catch (error) {
UNCOV
2731
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2732
                }
2733
            }
2734
        }
2735

2736
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2737
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2738
            return inMemory;
14✔
2739
        }
2740

2741
        // Rebuild a valid accessor expression for the device lookup. Joining with `.` is wrong for indexed
2742
        // segments (ex: `m.services[0]` would become the invalid `m.services.0` and the index gets dropped).
2743
        const expression = this.buildVariableExpression(parentVariablePath);
6✔
2744

2745
        const cacheKey = `${frameId}:${expression}`;
6✔
2746
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2747
            return this.completionParentVariableCache.get(cacheKey);
1✔
2748
        }
2749

2750
        let parentVariable: AugmentedVariable;
2751
        try {
5✔
2752
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2753
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2754
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
2755
        } catch (error) {
2756
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2757
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1✔
2758
            parentVariable = undefined;
1✔
2759
        }
2760

2761
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2762
        return parentVariable;
5✔
2763
    }
2764

2765
    /**
2766
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2767
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2768
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
2769
     * use dot access. This keeps array indices and string keys correct through the device lookup.
2770
     */
2771
    private buildVariableExpression(segments: string[]): string {
2772
        return segments.reduce((expression, segment, index) => {
14✔
2773
            if (index === 0) {
27✔
2774
                return segment;
14✔
2775
            }
2776
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2777
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
2778
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
2779
                return `${expression}[${segment}]`;
2✔
2780
            }
2781
            if (/^[0-9]+$/.test(segment)) {
11✔
2782
                return `${expression}[${segment}]`;
3✔
2783
            }
2784
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
2785
                return `${expression}.${segment}`;
5✔
2786
            }
2787
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
2788
        }, '');
2789
    }
2790

2791
    /**
2792
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
2793
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
2794
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
2795
     * the user typed (ex: `topRef` matching the cached `topref`).
2796
     */
2797
    private normalizeVariableName(name: string): string {
2798
        let value = name ?? '';
46!
2799
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46✔
2800
            value = value.slice(1, -1).replace(/""/g, '"');
2✔
2801
        }
2802
        return value.toLowerCase();
46✔
2803
    }
2804

2805
    /**
2806
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
2807
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
2808
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
2809
     */
2810
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
2811
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
2812
        if (path.length === 1 && path[0] === '') {
20✔
2813
            return localsContainer;
4✔
2814
        }
2815
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
2816
    }
2817

2818
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
2819
        let current: AugmentedVariable = null;
22✔
2820
        for (const name of path) {
22✔
2821
            const normalizedName = this.normalizeVariableName(name);
26✔
2822
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
2823
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26!
2824
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20✔
2825
            });
2826

2827
            // If no match is found, return null
2828
            if (!current) {
26✔
2829
                return null;
7✔
2830
            }
2831

2832
            // Move to the children for the next iteration
2833
            variables = current.childVariables;
19✔
2834
        }
2835
        return current;
15✔
2836
    }
2837

2838
    private debuggerVarTypeToRoType(type: string): string {
2839
        switch (type) {
15!
2840
            case VariableType.Function:
2841
            case VariableType.Subroutine:
UNCOV
2842
                return 'roFunction';
×
2843
            case VariableType.AssociativeArray:
2844
                return 'roAssociativeArray';
11✔
2845
            case VariableType.List:
UNCOV
2846
                return 'roList';
×
2847
            case VariableType.Array:
2848
                return 'roArray';
1✔
2849
            case VariableType.Boolean:
UNCOV
2850
                return 'roBoolean';
×
2851
            case VariableType.Double:
UNCOV
2852
                return 'roDouble';
×
2853
            case VariableType.Float:
UNCOV
2854
                return 'roFloat';
×
2855
            case VariableType.Integer:
UNCOV
2856
                return 'roInteger';
×
2857
            case VariableType.LongInteger:
UNCOV
2858
                return 'roLongInteger';
×
2859
            case VariableType.String:
UNCOV
2860
                return 'roString';
×
2861
            default:
2862
                return type;
3✔
2863
        }
2864
    }
2865

2866
    /**
2867
     * Called when the host stops debugging
2868
     * @param response
2869
     * @param args
2870
     */
2871
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
2872
        //return to the home screen — best effort. The device may already be powered off or unreachable
2873
        //at disconnect time; without a guard pressHomeButton rejects (EHOSTDOWN / ECONNREFUSED / etc)
2874
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
2875
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
2876
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
2877
        //    https://github.com/rokucommunity/roku-debug/issues/332
2878
        if (!this.enableDebugProtocol) {
2!
2879
            try {
2✔
2880
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
2881
            } catch (e) {
2882
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
2883
            }
2884
        }
2885
        this.sendResponse(response);
2✔
2886
        await this.shutdown();
2✔
2887
    }
2888

2889
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
2890
        if (this.enableDebugProtocol) {
1!
UNCOV
2891
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
2892
        } else {
2893
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1✔
2894
        }
2895
    }
2896

2897
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
UNCOV
2898
        this.logger.log('[restartRequest] begin');
×
UNCOV
2899
        if (this.rokuAdapter) {
×
UNCOV
2900
            if (!this.enableDebugProtocol) {
×
UNCOV
2901
                this.rokuAdapter.removeAllListeners();
×
2902
            }
UNCOV
2903
            await this.rokuAdapter.destroy();
×
UNCOV
2904
            await this.ensureAppIsInactive();
×
UNCOV
2905
            this.rokuAdapterDeferred = defer();
×
UNCOV
2906
            this.stagingDefered.tryResolve();
×
UNCOV
2907
            this.stagingDefered = defer();
×
2908
        }
UNCOV
2909
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
2910
    }
2911

2912
    private exitAppTimeout = 5000;
200✔
2913
    private async ensureAppIsInactive() {
UNCOV
2914
        const startTime = Date.now();
×
2915

UNCOV
2916
        while (true) {
×
UNCOV
2917
            if (Date.now() - startTime > this.exitAppTimeout) {
×
UNCOV
2918
                return;
×
2919
            }
2920

UNCOV
2921
            try {
×
UNCOV
2922
                let appStateResult = await rokuECP.getAppState({
×
2923
                    host: this.launchConfiguration.host,
2924
                    remotePort: this.launchConfiguration.remotePort,
2925
                    appId: 'dev',
2926
                    requestOptions: { timeout: 300 }
2927
                });
2928

UNCOV
2929
                const state = appStateResult.state;
×
2930

UNCOV
2931
                if (state === AppState.active || state === AppState.background) {
×
2932
                    // Suspends or terminates an app that is running:
2933
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
2934
                    // If the app supports Instant Resume and is running in the background or the app does not support Instant Resume and is running, sending this command terminates the app.
2935
                    // This means that we might need to send this command twice to terminate the app.
UNCOV
2936
                    await rokuECP.exitApp({
×
2937
                        host: this.launchConfiguration.host,
2938
                        remotePort: this.launchConfiguration.remotePort,
2939
                        appId: 'dev',
2940
                        requestOptions: { timeout: 300 }
2941
                    });
UNCOV
2942
                } else if (state === AppState.inactive) {
×
UNCOV
2943
                    return;
×
2944
                }
2945
            } catch (e) {
UNCOV
2946
                this.logger.error('Error attempting to exit application', e);
×
2947
            }
2948

UNCOV
2949
            await util.sleep(200);
×
2950
        }
2951
    }
2952

2953
    /**
2954
     * Used to track whether the entry breakpoint has already been handled
2955
     */
2956
    private entryBreakpointWasHandled = false;
200✔
2957

2958
    /**
2959
     * Registers the main events for the RokuAdapter
2960
     */
2961
    private async connectRokuAdapter() {
UNCOV
2962
        this.rokuAdapter.on('start', () => {
×
2963
            this.sendLaunchProgress('end', 'Complete');
×
UNCOV
2964
            if (!this.firstRunDeferred.isCompleted) {
×
UNCOV
2965
                this.firstRunDeferred.resolve();
×
2966
            }
2967
        });
2968

2969
        this.rokuAdapter.on('launch-status', (message) => {
×
UNCOV
2970
            this.sendLaunchProgress('update', message);
×
2971
        });
2972

2973
        //when the debugger suspends (pauses for debugger input)
2974
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
2975
        this.rokuAdapter.on('suspend', async () => {
×
UNCOV
2976
            await this.onSuspend();
×
2977
        });
2978

2979
        //anytime the adapter encounters an exception on the roku,
2980
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
UNCOV
2981
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
UNCOV
2982
            await this.getRokuAdapter();
×
UNCOV
2983
            const threads = await this.setupSuspendedState();
×
UNCOV
2984
            let threadId = threads[0]?.threadId;
×
UNCOV
2985
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
2986
        });
2987

2988
        // If the roku says it can't continue, we are no longer able to debug, so kill the debug session
UNCOV
2989
        this.rokuAdapter.on('cannot-continue', () => {
×
UNCOV
2990
            this.shutdown().catch(e => this.logger.error(e));
×
2991
        });
2992

2993
        //make the connection
UNCOV
2994
        await this.rokuAdapter.connect();
×
UNCOV
2995
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
UNCOV
2996
        return this.rokuAdapter;
×
2997
    }
2998

2999
    private async onSuspend() {
3000
        const threads = await this.setupSuspendedState();
1✔
3001
        const activeThread = threads.find(x => x.isSelected);
1✔
3002

3003
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3004
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
3005
            this.entryBreakpointWasHandled = true;
1✔
3006
            //if there's a user-defined breakpoint at this exact position, it needs to be handled like a regular breakpoint (i.e. suspend). So only auto-continue if there's no breakpoint here
3007
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
3008
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
UNCOV
3009
                return this.rokuAdapter.continue();
×
3010
            }
3011
        }
3012

3013
        const event: StoppedEvent = new StoppedEvent(
1✔
3014
            StoppedEventReason.breakpoint,
3015
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
3016
            activeThread?.threadId ?? 0,
6!
3017
            '' //exception text
3018
        );
3019
        // Socket debugger will always stop all threads and supports multi thread inspection.
3020
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
3021
        this.sendEvent(event);
1✔
3022
    }
3023

3024
    private async setupSuspendedState() {
3025
        //clear the index for storing evalutated expressions
3026
        this.evaluateVarIndexByFrameId.clear();
8✔
3027

3028
        const threads = await this.rokuAdapter.getThreads();
8✔
3029

3030
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3031
        //look up the correct line numbers for each thread from the StackTrace
3032
        await Promise.all(
8✔
3033
            threads.map(async (thread) => {
3034
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3035
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3036
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
3037
                // Only apply the line correction when we actually have valid data — never clobber
3038
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3039
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9✔
3040
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3041
                    thread.lineNumber = stackTraceLineNumber;
2✔
3042
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3043
                }
3044
            })
3045
        );
3046

3047
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3048
            for (const thread of threads) {
4✔
3049
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
3050
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3051
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3052
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4✔
3053
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
UNCOV
3054
                        this.logger.error('Error showing popup message', { error });
×
3055
                    });
3056
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3057
                    break outer;
1✔
3058
                }
3059
            }
3060
        }
3061

3062
        //sync breakpoints
3063
        await this.rokuAdapter?.syncBreakpoints();
8!
3064

3065
        this.logger.info('received "suspend" event from adapter');
8✔
3066

3067
        this.clearState();
8✔
3068
        return threads;
8✔
3069
    }
3070

3071
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3072
        let v: AugmentedVariable;
3073

3074
        if (result) {
25!
3075
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3076
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3077
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
UNCOV
3078
                    try {
×
3079
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3080
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
3081
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3082
                        this.mergeEvaluateContainers(result, newResult);
×
3083
                    } catch (error) {
UNCOV
3084
                        logger.error('Error getting variables', error);
×
UNCOV
3085
                        this.mergeEvaluateContainers(result, {
×
3086
                            name: result.name,
3087
                            evaluateName: result.evaluateName,
3088
                            children: [],
3089
                            value: `❌ Error: ${error.message}`,
3090
                            type: '',
3091
                            highLevelType: undefined,
3092
                            keyType: undefined
3093
                        });
3094
                    }
3095
                }
3096

3097
                if (result.keyType) {
16✔
3098
                    let value = `${result.value ?? result.type}`;
5!
3099
                    let indexedVariables = result.indexedVariables;
5✔
3100
                    let namedVariables = result.namedVariables;
5✔
3101

3102
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
3103
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3104
                        // by supplying undefined values for both
UNCOV
3105
                        indexedVariables = undefined;
×
3106
                        namedVariables = undefined;
×
3107
                    }
3108

3109
                    // check to see if this is an dictionary or a list
3110
                    if (result.keyType === 'Integer') {
5!
3111
                        // list type
3112
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3113
                    } else if (result.keyType === 'String') {
5!
3114
                        // dictionary type
3115
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5✔
3116
                    }
3117
                    v.type = result.type;
5✔
3118
                } else {
3119

3120
                    let value: string;
3121
                    if (result.type === VariableType.Invalid) {
11!
UNCOV
3122
                        value = result.value ?? 'Invalid';
×
3123
                    } else if (result.type === VariableType.Uninitialized) {
11!
UNCOV
3124
                        value = 'Uninitialized';
×
3125
                    } else {
3126
                        value = `${result.value}`;
11✔
3127
                    }
3128
                    // If the variable is lazy we must assign a refId to inform the system
3129
                    // to request this variable again in the future for value resolution
3130
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11!
3131
                }
3132
                this.variables[refId] = v;
16✔
3133
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3134
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3135
                    v = new Variable(result.name, `${result.value}`);
7✔
3136
                } else if (result.highLevelType === 'array') {
2✔
3137
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3138
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3139
                    this.variables[refId] = v;
1✔
3140
                } else if (result.highLevelType === 'object') {
1!
3141
                    let refId: number;
3142
                    //handle collections
3143
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3144
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3145
                    }
3146
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3147
                    this.variables[refId] = v;
1✔
UNCOV
3148
                } else if (result.highLevelType === 'function') {
×
UNCOV
3149
                    v = new Variable(result.name, `${result.value}`);
×
3150
                } else {
3151
                    //all other cases, but mostly for HighLevelType.unknown
UNCOV
3152
                    v = new Variable(result.name, `${result.value}`);
×
3153
                }
3154
            }
3155

3156
            v.type = result.type;
25✔
3157
            v.evaluateName = result.evaluateName;
25✔
3158
            v.frameId = frameId;
25✔
3159
            v.type = result.type;
25✔
3160
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25!
3161
            if (util.isTransientVariable(v.name)) {
25!
UNCOV
3162
                v.presentationHint = { kind: 'virtual' };
×
3163
            }
3164

3165
            if (result.children && maxDepth > 0) {
25✔
3166
                if (!v.childVariables) {
7!
3167
                    v.childVariables = [];
7✔
3168
                }
3169

3170
                // Create a mapping of the children to their index so we can evaluate them in bulk
3171
                let indexMappedChildren = result.children.map((child, index) => {
7✔
3172
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3173
                    return remapped;
10✔
3174
                });
3175
                if (this.enableDebugProtocol) {
7!
UNCOV
3176
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
UNCOV
3177
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
UNCOV
3178
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3179
                    });
3180

UNCOV
3181
                    let variablePathArray = childrenToEvaluate.map(x => {
×
UNCOV
3182
                        return util.getVariablePath(x.child.evaluateName);
×
3183
                    });
3184

UNCOV
3185
                    try {
×
UNCOV
3186
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
UNCOV
3187
                        if (bulkEvaluations.bulkVarName) {
×
UNCOV
3188
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
UNCOV
3189
                            childrenToEvaluate.map((mappedChild, index) => {
×
UNCOV
3190
                                let newResult = newResults.children[index];
×
UNCOV
3191
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
UNCOV
3192
                                mappedChild.child.evaluateNow = false;
×
UNCOV
3193
                                return mappedChild;
×
3194
                            });
3195
                        }
3196
                    } catch (error) {
3197
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3198
                    }
3199
                }
3200
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3201
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7✔
3202
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3203
                }));
3204
            } else {
3205
                v.childVariables = [];
18✔
3206
            }
3207

3208
            // if the var is an array and debugProtocol is enabled, include the array size
3209
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25!
UNCOV
3210
                if (isNaN(result.indexedVariables as number)) {
×
UNCOV
3211
                    v.value = v.type;
×
3212
                } else {
UNCOV
3213
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3214
                }
3215
            }
3216
        }
3217
        return v;
25✔
3218
    }
3219

3220
    /**
3221
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3222
     * Used primarily for custom variables
3223
     */
3224
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
UNCOV
3225
        original.children = updated.children;
×
UNCOV
3226
        original.value = updated.value;
×
UNCOV
3227
        original.type = updated.type;
×
UNCOV
3228
        original.highLevelType = updated.highLevelType;
×
3229
        original.keyType = updated.keyType;
×
UNCOV
3230
        original.indexedVariables = updated.indexedVariables;
×
UNCOV
3231
        original.namedVariables = updated.namedVariables;
×
3232
    }
3233

3234
    private getEvaluateRefId(expression: string, frameId: number) {
3235
        let evaluateRefId = `${expression}-${frameId}`;
77✔
3236
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77✔
3237
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45✔
3238
        }
3239
        return this.evaluateRefIdLookup[evaluateRefId];
77✔
3240
    }
3241

3242
    private clearState() {
3243
        //erase all cached variables
3244
        this.variables = {};
12✔
3245
        this.completionParentVariableCache.clear();
12✔
3246
    }
3247

3248
    /**
3249
     * Sends a launch progress event to the client if the client supports progress reporting.
3250
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3251
     * - `'update'`: updates the message on the active progress bar.
3252
     * - `'end'`: dismisses the active progress bar with an optional final message.
3253
     */
3254
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3255
        if (!this.initRequestArgs?.supportsProgressReporting) {
66✔
3256
            return;
35✔
3257
        }
3258
        if (type === 'start') {
31✔
3259
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
10✔
3260
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
10✔
3261
        } else if (this.launchProgressId) {
21✔
3262
            if (type === 'update') {
18✔
3263
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3264
            } else {
3265
                const lastId = this.launchProgressId;
5✔
3266
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
5✔
3267
                setTimeout(() => {
5✔
3268
                    this.sendEvent(new ProgressEndEvent(lastId, message));
5✔
3269
                }, 1000); // add a slight delay before ending the progress to improve UX
3270
                this.launchProgressId = undefined;
5✔
3271
            }
3272
        }
3273
    }
3274

3275
    /**
3276
     * Tells the client to re-request all variables because we've invalidated them
3277
     * @param threadId
3278
     * @param stackFrameId
3279
     */
3280
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3281
        //if the client supports this request, send it
3282
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3!
UNCOV
3283
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3284
        }
3285
    }
3286

3287
    /**
3288
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3289
     */
3290
    public async handleEntryBreakpoint() {
3291
        if (!this.enableDebugProtocol) {
4!
3292
            this.entryBreakpointWasHandled = true;
4✔
3293
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3294
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
3295
            }
3296
        }
3297
    }
3298

3299
    /**
3300
     * Converts a debugger line number to a client line number.
3301
     *
3302
     * @param debuggerLine - The line number from the debugger as zero based.
3303
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3304
     * @returns The corresponding client line number.
3305
     */
3306
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3307
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3!
3308
    }
3309

3310
    /**
3311
     * Converts a debugger column number to a client column number.
3312
     *
3313
     * @param debuggerLine - The column number from the debugger as zero based.
3314
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3315
     * @returns The corresponding client column number.
3316
     */
3317
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3318
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3!
3319
    }
3320

3321
    /**
3322
     * Converts a client line number to a debugger line number.
3323
     *
3324
     * @param clientLine - The line number from the client.
3325
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3326
     * @returns The corresponding debugger line number as zero based.
3327
     */
3328
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3329
        if (typeof clientLine === 'number') {
109✔
3330
            return this.convertClientLineToDebugger(clientLine);
2✔
3331
        }
3332
        return defaultDebuggerLine;
107✔
3333
    }
3334

3335
    /**
3336
     * Converts a client column number to a debugger column number.
3337
     *
3338
     * @param clientLine - The column number from the client.
3339
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3340
     * @returns The corresponding debugger column number as zero based.
3341
     */
3342
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3343
        if (typeof clientLine === 'number') {
89!
3344
            return this.convertClientColumnToDebugger(clientLine);
89✔
3345
        }
3346
        return defaultDebuggerLine;
×
3347
    }
3348

3349
    private shutdownPromise: Promise<void> | undefined = undefined;
200✔
3350

3351
    /**
3352
     * Called when the debugger is terminated. Feel free to call this as frequently as you want; we'll only run the shutdown process the first time, and return
3353
     * the same promise on subsequent calls
3354
     */
3355
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
15✔
3356
        if (this.shutdownPromise === undefined) {
15!
3357
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
15✔
3358
            this.shutdownPromise = this._shutdown(errorMessage, modal);
15✔
3359
        } else {
UNCOV
3360
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3361
        }
3362
        return this.shutdownPromise;
15✔
3363
    }
3364

3365
    private async _shutdown(errorMessage?: string, modal = false): Promise<void> {
×
3366
        // Ensure any active launch progress bar is dismissed before showing error messages or the terminated event.
3367
        this.sendLaunchProgress('end', 'Complete');
15✔
3368

3369
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3370
        try {
15✔
3371
            if (errorMessage) {
15!
UNCOV
3372
                this.logger.error(errorMessage);
×
UNCOV
3373
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
UNCOV
3374
                    this.logger.error('Error showing popup message', { error });
×
3375
                });
3376
            }
3377
        } catch (e) {
UNCOV
3378
            this.logger.error(e);
×
3379
        }
3380
        // stop perfetto tracing if it's running
3381
        try {
15✔
3382
            await this.perfettoManager.stopTracing();
15✔
3383
        } catch (e) {
3384
            this.logger.error('Error stopping perfetto tracing', e);
15✔
3385
        }
3386

3387
        try {
15✔
3388
            await this.perfettoManager?.dispose?.();
15!
3389
        } catch (e) {
UNCOV
3390
            this.logger.error('Error disposing perfetto manager', e);
×
3391
        }
3392

3393
        //close the debugger connection
3394
        try {
15✔
3395
            this.logger.log('Destroy rokuAdapter');
15✔
3396
            await this.rokuAdapter?.destroy?.();
15!
3397
            //press the home button to return to the home screen
3398
            try {
15✔
3399
                this.logger.log('Press home button');
15✔
3400
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
15✔
3401
            } catch (e) {
UNCOV
3402
                this.logger.error(e);
×
3403
            }
3404
        } catch (e) {
UNCOV
3405
            this.logger.error(e);
×
3406
        }
3407

3408
        try {
15✔
3409
            this.projectManager?.dispose?.();
15!
3410
        } catch (e) {
UNCOV
3411
            this.logger.error(e);
×
3412
        }
3413

3414
        try {
15✔
3415
            this.componentLibraryServer?.stop();
15!
3416
        } catch (e) {
UNCOV
3417
            this.logger.error(e);
×
3418
        }
3419

3420
        try {
15✔
3421
            await this.rendezvousTracker?.destroy?.();
15!
3422
        } catch (e) {
UNCOV
3423
            this.logger.error(e);
×
3424
        }
3425

3426
        try {
15✔
3427
            await this.sourceMapManager?.destroy?.();
15!
3428
        } catch (e) {
UNCOV
3429
            this.logger.error(e);
×
3430
        }
3431

3432
        try {
15✔
3433
            //if configured, delete the staging directory
3434
            if (!this.launchConfiguration.retainStagingFolder) {
15!
3435
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
15!
3436
                this.logger.info('deleting staging folders', stagingDirs);
15✔
3437
                for (let stagingDir of stagingDirs) {
15✔
3438
                    try {
2✔
3439
                        fsExtra.removeSync(stagingDir);
2✔
3440
                    } catch (e) {
UNCOV
3441
                        this.logger.error(e);
×
UNCOV
3442
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3443
                    }
3444
                }
3445
            }
3446
        } catch (e) {
UNCOV
3447
            this.logger.error(e);
×
3448
        }
3449

3450
        try {
15✔
3451
            this.logger.log('Send terminated event');
15✔
3452
            this.sendEvent(new TerminatedEvent());
15✔
3453

3454
            //shut down the process
3455
            this.logger.log('super.shutdown()');
15✔
3456
            super.shutdown();
15✔
3457
            this.logger.log('shutdown complete');
15✔
3458
        } catch (e) {
UNCOV
3459
            this.logger.error(e);
×
3460
        }
3461

3462
        try {
15✔
3463
            this.teardownProcessErrorHandlers();
15✔
3464
        } catch (e) {
UNCOV
3465
            this.logger.error(e);
×
3466
        }
3467
    }
3468
}
3469

3470
export interface AugmentedVariable extends DebugProtocol.Variable {
3471
    childVariables?: AugmentedVariable[];
3472
    // eslint-disable-next-line camelcase
3473
    request_seq?: number;
3474
    frameId?: number;
3475
    /**
3476
     * only used for lazy variables
3477
     */
3478
    isResolved?: boolean;
3479
    /**
3480
     * used to indicate that this variable is a scope variable
3481
     * and may require special handling
3482
     */
3483
    isScope?: boolean;
3484
}
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

© 2026 Coveralls, Inc