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

rokucommunity / roku-debug / 29091048042

10 Jul 2026 11:57AM UTC coverage: 73.586% (+0.8%) from 72.802%
29091048042

Pull #323

github

web-flow
Merge a7a0b7642 into 7aa945dd8
Pull Request #323: Add component library postfix to `library` statements

3842 of 5489 branches covered (69.99%)

Branch coverage included in aggregate %.

66 of 102 new or added lines in 2 files covered. (64.71%)

1442 existing lines in 106 files now uncovered.

6296 of 8288 relevant lines covered (75.97%)

45.79 hits per line

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

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

215✔
76
const diagnosticSource = 'roku-debug';
215✔
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 {
215✔
83
    Member = '1',
215✔
84
    Method = '2',
85
    ScopeFunction = '3',
86
    Global = '4'
87
}
215✔
88

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

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

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

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

215✔
113
    public start(inStream: NodeJS.ReadableStream, outStream: NodeJS.WritableStream): void {
215✔
114
        super.start(inStream, outStream);
115

116
        //When the client's pipe goes away (e.g. VS Code closed), stop forwarding output. Otherwise the
117
        //still-running Roku app keeps streaming output, every write to the dead pipe fails, and each
215✔
118
        //failure re-triggers shutdown() in a tight loop that pegs the CPU and orphans this process.
119
        const markClientGone = () => {
120
            this.clientDisconnected = true;
121
        };
3✔
122
        inStream?.on?.('close', markClientGone);
123
        inStream?.on?.('end', markClientGone);
124
        outStream?.on?.('error', markClientGone);
125

3✔
126
        // Set up DAP protocol logging as early as possible — immediately after start() so we capture
1✔
127
        // the initialize request and all early DAP traffic before launchRequest config is available.
128
        // The log file path is injected as ROKU_DAP_LOG_FILE by the extension's DebugAdapterDescriptorFactory,
3✔
129
        // which resolves the path from the `brightscript.debug.debugAdapterProtocolLogging` workspace setting
3✔
130
        // (or the equivalent launch.json property) before the debug adapter process is spawned.
3✔
131
        const dapLogFile = process.env.ROKU_DAP_LOG_FILE;
132
        if (dapLogFile) {
133
            // Use LogLevel.Error (not Verbose) as the console threshold so DAP messages are written
134
            // to the log file but are NOT forwarded to VS Code as OutputEvents, which would flood
135
            // the debug console and break the extension's output parsing.
136
            // Note: InternalLogger always writes ALL messages to the file stream regardless of level,
3✔
137
            // so the log file will still contain everything.
3✔
138
            dapLogger.setup(DapLogger.LogLevel.Error, dapLogFile);
139
        }
140
    }
141

142
    /**
143
     * Once the client has disconnected, drop outgoing events instead of writing to the dead stream.
1✔
144
     * Writing to a broken pipe re-triggers the base 'error' -> shutdown() handler in a tight loop.
145
     */
146
    public sendEvent(event: DebugProtocol.Event): void {
147
        if (this.clientDisconnected) {
148
            return;
149
        }
150
        super.sendEvent(event);
151
    }
674✔
152

4✔
153
    public setupProcessErrorHandlers() {
154
        if (this.processErrorHandlersRegistered) {
670✔
155
            return;
156
        }
157
        this.processErrorHandlersRegistered = true;
16✔
158

1✔
159
        this._uncaughtExceptionHandler = (error) => this.handleProcessError('uncaughtException', error);
160
        this._unhandledRejectionHandler = (reason) => this.handleProcessError('unhandledRejection', reason);
15✔
161

15✔
162
        process.on('uncaughtException', this._uncaughtExceptionHandler);
15✔
163
        process.on('unhandledRejection', this._unhandledRejectionHandler);
15✔
164
    }
15✔
165

166
    private handlingProcessError = false;
167

168
    /**
169
     * True when an error indicates the client (e.g. VS Code) has gone away, such as a broken stdout pipe
170
     * (EPIPE). Writing to a dead pipe just produces more EPIPEs, so we must not try to report over it.
171
     */
21!
172
    private isClientGoneError(error: unknown): boolean {
21!
173
        const code = (error as NodeJS.ErrnoException)?.code;
21✔
174
        const message = error instanceof Error ? error.message : String(error ?? '');
175
        return code === 'EPIPE' || /\bEPIPE\b|write after end/i.test(message);
176
    }
177

178
    /**
179
     * Tear down the process error handlers and forcibly exit, so we never leave an orphaned adapter
1✔
180
     * spinning in the background after the client is gone or a graceful shutdown has hung.
1✔
181
     */
1✔
182
    private forceExit(code = 0): void {
183
        this.teardownProcessErrorHandlers();
184
        process.exit(code);
185
    }
186

187
    private handleProcessError(type: 'uncaughtException' | 'unhandledRejection', error: unknown) {
188
        //a broken client pipe (EPIPE) means the client (e.g. VS Code) is gone. Trying to report it over
18✔
189
        //the now-dead stream just produces more EPIPEs, which re-enter this handler in a tight loop and
1✔
190
        //peg the CPU. Exit instead.
1✔
191
        if (this.isClientGoneError(error)) {
1✔
192
            this.clientDisconnected = true;
193
            this.forceExit();
194
            return;
195
        }
17✔
196
        //only handle the first error; re-entering here (e.g. from a failed write while reporting) would
2✔
197
        //spin the CPU and flood the logs
198
        if (this.handlingProcessError) {
15✔
199
            return;
15✔
200
        }
15✔
201
        this.handlingProcessError = true;
15✔
202

15✔
203
        const logger = this.logger.createLogger(`${type}`);
204
        const message = error instanceof Error ? error.message : String(error);
205
        const stack = error instanceof Error ? error.stack : undefined;
206
        logger.error(message, stack);
15✔
207

15✔
208
        let output: string;
14✔
209
        let debuggerVersion: string;
14✔
210
        let additionalInfo: ProcessCrashEventData['additionalInfo'];
211
        try {
212
            debuggerVersion = (fsExtra.readJsonSync(path.resolve(__dirname, '../../package.json')) as { version: string }).version;
42!
213

42!
214
            const clientName = this.initRequestArgs?.clientName ?? 'unknown';
14!
215

42!
216
            additionalInfo = {
217
                clientName: clientName,
218
                rokuDebugVersion: debuggerVersion,
14✔
219
                ecpMode: this.deviceInfo?.ecpSettingMode,
220
                developerMode: this.deviceInfo?.developerEnabled,
98✔
221
                firmware: this.deviceInfo ? `${this.deviceInfo?.softwareVersion}.${this.deviceInfo?.softwareBuild}` : undefined,
98✔
222
                protocolVersion: this.deviceInfo?.brightscriptDebuggerVersion,
98✔
223
                protocolEnabled: this.enableDebugProtocol
224
            };
14✔
225

226
            const lines = Object.entries(additionalInfo as Record<string, unknown>).map(([key, value]) => {
227
                // Insert a space before all uppercase letters preceded by a lowercase letter, then uppercase the first char
228
                const spacedString = key.replace(/([a-z])([A-Z])/g, '$1 $2');
229
                const formattedKey = spacedString.charAt(0).toUpperCase() + spacedString.slice(1);
230
                return `**${formattedKey}:** ${JSON.stringify(value)}`;
231
            });
232

233
            const issueBodyPrefix = [
234
                `**Error type:** ${type}`,
235
                `**Message:** ${message}`,
236
                ...lines,
14✔
237
                '',
14✔
238
                `**Steps to reproduce:**`,
14✔
239
                `<!-- Please describe what you were doing when this crash occurred -->`,
14✔
240
                '',
14✔
241
                '**Stack trace:**',
14✔
242
                '```',
14✔
243
                ''
14✔
244
            ].join('\n');
14✔
245
            const issueBodySuffix = '\n```';
246

14✔
247
            const issueTitle = encodeURIComponent(`[crash] ${type}: ${message}`);
2✔
248
            const baseUrl = 'https://github.com/RokuCommunity/roku-debug/issues/new';
249
            const maxUrlLength = 2000;
12✔
250
            const urlOverhead = `${baseUrl}?title=${issueTitle}&body=`.length;
4✔
251
            const bodyBudget = maxUrlLength - urlOverhead;
252
            const encodedPrefix = encodeURIComponent(issueBodyPrefix);
253
            const encodedSuffix = encodeURIComponent(issueBodySuffix);
8✔
254
            const stackBudget = bodyBudget - encodedPrefix.length - encodedSuffix.length;
255
            let truncatedStack: string;
11✔
256
            if (!stack) {
11✔
257
                truncatedStack = '(no stack trace)';
258
            } else if (encodeURIComponent(stack).length <= stackBudget) {
259
                truncatedStack = stack;
260
            } else {
261
                truncatedStack = decodeURIComponent(encodeURIComponent(stack).slice(0, stackBudget)) + '\n...(truncated)';
262
            }
263
            const issueUrl = `${baseUrl}?title=${issueTitle}&body=${encodedPrefix}${encodeURIComponent(truncatedStack)}${encodedSuffix}`;
264

77✔
265
            output = [
266
                '',
267
                '================================================================',
84✔
268
                '\tBRIGHTSCRIPT DEBUGGER INTERNAL ERROR',
269
                '\tThis is a crash in the debug adapter, not in your application.',
270
                '================================================================',
271
                `\tError type: ${type}`,
272
                `\tMessage: ${message}`,
273
                ...lines.map(l => `\t${l}`),
274
                '',
275
                '\tStack trace:',
276
                ...(stack ?? '(no stack trace)').split('\n').map(l => `\t${l}`),
4✔
277
                '',
278
                '\tPlease report this at:',
279
                `\t${issueUrl}`,
280
                '================================================================',
281
                ''
282
            ].join('\n');
15✔
283
        } catch (e) {
15✔
284
            output = JSON.stringify({
15✔
285
                name: e.name,
15✔
286
                message: e.message,
287
                stack: e.stack
288
            });
32✔
289
        }
15✔
290

15✔
291
        void this.sendLogOutput(output).catch(() => { /** best-effort */ });
292
        this.isCrashed = true;
32✔
293
        this.sendEvent(new ProcessCrashEvent({ type, message, stack, additionalInfo: additionalInfo ?? {} }));
15✔
294
        setTimeout(() => void this.shutdown(), 5000).unref();
15✔
295
    }
296

32✔
297
    public teardownProcessErrorHandlers() {
298
        if (this._uncaughtExceptionHandler) {
299
            process.removeListener('uncaughtException', this._uncaughtExceptionHandler);
3✔
300
            this._uncaughtExceptionHandler = undefined;
301
        }
3✔
302
        if (this._unhandledRejectionHandler) {
3✔
303
            process.removeListener('unhandledRejection', this._unhandledRejectionHandler);
304
            this._unhandledRejectionHandler = undefined;
305
        }
306
        this.processErrorHandlersRegistered = false;
307
    }
308

309
    private onDeviceBreakpointsChanged(eventName: 'changed' | 'new', data: { breakpoints: AugmentedSourceBreakpoint[] }) {
310
        this.logger.info('Sending verified device breakpoints to client', data);
311
        //send all verified breakpoints to the client
312
        for (const breakpoint of data.breakpoints) {
313
            const event: DebugProtocol.Breakpoint = {
3✔
314
                line: breakpoint.line,
315
                column: breakpoint.column,
316
                verified: breakpoint.verified,
317
                id: breakpoint.id,
318
                reason: breakpoint.reason,
77!
319
                message: breakpoint.message,
320
                source: {
321
                    path: breakpoint.srcPath
322
                }
323
            };
324
            this.sendEvent(new BreakpointEvent(eventName, event));
325
        }
13✔
326
    }
13!
327

328
    public logger = logger.createLogger(`[session]`);
329

330
    private readonly isWindowsPlatform = process.platform.startsWith('win');
331

332
    /**
26✔
333
     * A sequence used to help identify log statements for requests
26✔
334
     */
26✔
335
    private idCounter = 1;
336

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
2✔
342

2✔
343
    private processErrorHandlersRegistered = false;
2✔
344
    private isCrashed = false;
345
    /** Set once the client (e.g. VS Code) disconnects, so we stop writing to a now-dead stream */
2✔
346
    private clientDisconnected = false;
347
    /** How long to wait for a graceful shutdown before forcibly exiting the process */
2✔
348
    private shutdownForceExitTimeout = 10_000;
349
    private _uncaughtExceptionHandler: ((error: Error) => void) | undefined;
2✔
350
    private _unhandledRejectionHandler: ((reason: unknown) => void) | undefined;
351

352
    public breakpointManager: BreakpointManager;
353

354
    public locationManager: LocationManager;
355

356
    public sourceMapManager: SourceMapManager;
357

358
    //set imports as class properties so they can be spied upon during testing
359
    public rokuDeploy = rokuDeploy as unknown as RokuDeploy;
360

2✔
361
    private componentLibraryServer = new ComponentLibraryServer();
362

363
    private rokuAdapterDeferred = defer<DebugProtocolAdapter | TelnetAdapter>();
364
    /**
365
     * A promise that is resolved whenever the app has started running for the first time
366
     */
367
    private firstRunDeferred = defer<void>();
368

369
    /**
370
     * Resolved whenever we're finished copying all the files to staging for all projects
371
     */
372
    private stagingDefered = defer<void>();
373

374
    private evaluateRefIdLookup: Record<string, number> = {};
375
    private evaluateRefIdCounter = 1;
2✔
376

2✔
377
    private variables: Record<number, AugmentedVariable> = {};
2✔
378

379
    /**
2✔
380
     * Caches the device lookups performed while resolving completion requests. Variables don't change
538✔
381
     * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
382
     * `clearState` whenever the debugger resumes or steps.
2✔
383
     */
384
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
385

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
9!
387

9✔
388
    private perfettoManager: PerfettoManager;
389

9✔
390
    private rendezvousTracker: RendezvousTracker;
2✔
391

392
    public tempVarPrefix = '__rokudebug__';
393

394
    /**
395
     * The progressId of the active launch progress bar, if any.
7✔
396
     * Cleared once the ProgressEndEvent is sent.
9✔
397
     */
398
    private launchProgressId: string | undefined;
399

400
    /**
9✔
401
     * The first encountered compile error, will be used to send to the client as a runtime error (nicer UI presentation)
402
     */
403
    private compileError: BSDebugDiagnostic;
404

9✔
405
    /**
9✔
406
     * 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
407
     */
408
    private COMPILE_ERROR_THREAD_ID = 7_777;
8✔
409

7✔
410
    private get enableDebugProtocol() {
411
        return this.launchConfiguration?.enableDebugProtocol;
412
    }
413

414
    /**
415
     * Check if the Roku firmware supports Perfetto tracing (requires OS 15.2 or higher)
1✔
416
     */
417
    private get supportsPerfettoTracing() {
418
        this.logger.log('Checking if device supports Perfetto tracing', this.deviceInfo.softwareVersion);
419
        return semver.satisfies(this.deviceInfo?.softwareVersion ?? '0.0', '>= 15.2');
420
    }
421

422
    /**
423
     * Get a promise that resolves when the roku adapter is ready to be used
1✔
424
     */
425
    private async getRokuAdapter() {
426
        await this.rokuAdapterDeferred.promise;
427
        await this.rokuAdapter.onReady();
1✔
428
        return this.rokuAdapter;
429
    }
430

9✔
431
    private launchConfiguration: LaunchConfiguration;
432
    private initRequestArgs: DebugProtocol.InitializeRequestArguments;
433

UNCOV
434
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
×
UNCOV
435

×
436
    /**
UNCOV
437
     * The 'initialize' request is the first request called by the frontend
×
438
     * to interrogate the features the debug adapter provides.
439
     */
440
    public initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void {
4✔
441
        this.initRequestArgs = args;
4✔
442
        this.logger.log('initializeRequest');
2✔
443

444
        response.body ||= {};
445

3✔
446
        // This debug adapter implements the configurationDoneRequest.
3✔
447
        response.body.supportsConfigurationDoneRequest = true;
3✔
448

1!
449
        // 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.
1!
UNCOV
450
        response.body.supportsRestartRequest = true;
×
451

452
        // make VS Code to use 'evaluate' when hovering over source
453
        response.body.supportsEvaluateForHovers = true;
1✔
454

455
        //NOTE: `supportsConditionalBreakpoints` and `supportsHitConditionalBreakpoints` are
456
        //sent later in the post-connect CapabilitiesEvent once we know which adapter is in use
457
        //(telnet always supports them via stop-statement rewrites; debug protocol requires v3.1.0+).
458
        //VS Code reads these caps per-render in the BREAKPOINTS view, so CapabilitiesEvent updates
3✔
459
        //take effect immediately - unlike `exceptionBreakpointFilters` / `breakpointModes` which
460
        //must be in the initialize response.
461

9!
462
        //surface the filter list here so VS Code's BREAKPOINTS panel renders the checkboxes - the
463
        //panel only reads this list from the initialize response, not from later CapabilitiesEvents.
3✔
464
        //The `supportsExceptionFilterOptions` / `supportsExceptionOptions` booleans are deferred and
465
        //sent via CapabilitiesEvent once we know the connected device's protocol version.
466
        response.body.exceptionBreakpointFilters = [{
467
            filter: 'caught',
468
            supportsCondition: true,
469
            conditionDescription: '__brs_err__.rethrown = true',
470
            label: 'Caught Exceptions',
9!
471
            description: `Breaks on all errors, even if they're caught later.`,
472
            default: false
473
        }, {
474
            filter: 'uncaught',
475
            supportsCondition: true,
476
            conditionDescription: '__brs_err__.rethrown = true',
477
            label: 'Uncaught Exceptions',
478
            description: 'Breaks only on errors that are not handled.',
479
            default: true
480
        }];
9✔
481

9✔
482
        response.body.supportsCompletionsRequest = true;
9!
483
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
9!
484

9!
485
        this.sendResponse(response);
9!
486

9!
487
        //register the debug output log transport writer
9!
488
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
9!
489
            this.sendEvent(
9!
490
                new DebugServerLogOutputEvent(
9!
491
                    message.logger.formatMessage(message, false)
9!
492
                )
9!
493
            );
9!
494
        });
9!
495

9!
UNCOV
496
        this.logger.log('initializeRequest finished');
×
497
    }
UNCOV
498

×
499
    protected async setExceptionBreakPointsRequest(response: DebugProtocol.SetExceptionBreakpointsResponse, args: DebugProtocol.SetExceptionBreakpointsArguments) {
500
        response.body ??= {};
501
        try {
9!
502

9✔
503
            let filterOptions: ExceptionBreakpoint[];
504
            if (args.filterOptions) {
9!
505
                filterOptions = args.filterOptions.map(x => ({
9✔
506
                    filter: x.filterId as 'caught' | 'uncaught',
507
                    conditionExpression: x.condition
508
                }));
509
            } else if (args.filters) {
9✔
510
                filterOptions = args.filters.map(x => ({
9✔
511
                    filter: x as 'caught' | 'uncaught'
9✔
512
                }));
9✔
513
            }
9✔
514
            this.exceptionBreakpoints = filterOptions;
515

9!
516
            //wait until the adapter object exists, but don't wait for the device to come online —
517
            //VS Code will not send configurationDone (and we cannot launch the channel) until we
9!
UNCOV
518
            //respond to this request.
×
519
            await this.rokuAdapterDeferred.promise;
520

9✔
521
            if (this.rokuAdapter.supportsExceptionBreakpoints) {
522
                //the adapter queues these filters internally if the debug protocol client hasn't
9✔
523
                //connected yet, and replays them once it does
9✔
524
                await this.rokuAdapter.setExceptionBreakpoints(filterOptions);
525
                response.body.breakpoints = [
UNCOV
526
                    { verified: true },
×
527
                    { verified: true }
528
                ];
529
            } else {
9✔
530
                response.body.breakpoints = [
9✔
531
                    { verified: false },
1✔
532
                    { verified: false }
533
                ];
534
            }
8✔
535
        } catch (e) {
536
            //if error (or not supported)
9!
UNCOV
537
            response.body.breakpoints = [
×
538
                { verified: false },
539
                { verified: false }
540
            ];
UNCOV
541
            this.logger.error('Failed to set exception breakpoints', e);
×
UNCOV
542
        } finally {
×
543
            this.sendResponse(response);
UNCOV
544
        }
×
545
    }
546

9!
UNCOV
547

×
548
    protected async setTransientsToInvalid() {
549
        let brsErr = Object.values(this.variables).find((v) => v.name === '__brs_err__');
550
        if (brsErr && brsErr.type !== VariableType.Uninitialized) {
9✔
551
            // Assigning the variable to the function call results in it becoming unintialized
552
            await this.rokuAdapter.evaluate(`__brs_err__ = [].clear()`, brsErr.frameId);
9!
553
        }
9✔
554
    }
9✔
555

9✔
556
    private async showPopupMessage<T extends string>(message: string, severity: 'error' | 'warn' | 'info', modal = false, actions?: T[]): Promise<T> {
9✔
557
        const response = await this.sendCustomRequest('showPopupMessage', { message: message, severity: severity, modal: modal, actions: actions });
9✔
558
        return response.selectedAction;
9!
559
    }
560

9✔
561
    private static requestIdSequence = 0;
562

563
    private async sendCustomRequest<T = any, R = any>(name: string, data: T): Promise<R> {
564
        const requestId = BrightScriptDebugSession.requestIdSequence++;
565
        const responsePromise = new Promise<R>((resolve, reject) => {
9✔
566
            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
567
                if (response.requestId === requestId) {
9!
UNCOV
568
                    if (response.error) {
×
569
                        throw response.error;
570
                    } else {
571
                        resolve(response as R);
572
                    }
9✔
573
                }
9!
UNCOV
574
            });
×
575
        });
576
        this.sendEvent(
577
            new CustomRequestEvent({
9✔
578
                requestId: requestId,
579
                name: name,
580
                ...data ?? {}
9✔
581
            }));
9✔
582
        return responsePromise;
9✔
583
    }
9✔
584

585
    /**
UNCOV
586
      * Get the cwd from the launchConfiguration, or default to process.cwd()
×
587
      */
588
    private get cwd() {
9✔
589
        return this.launchConfiguration?.cwd ?? process.cwd();
9✔
590
    }
9✔
591

592
    public deviceInfo: DeviceInfo;
593

9!
UNCOV
594
    /**
×
UNCOV
595
     * Set defaults and standardize values for all of the LaunchConfiguration values
×
UNCOV
596
     * @param config
×
597
     * @returns
598
     */
599
    private normalizeLaunchConfig(config: LaunchConfiguration) {
UNCOV
600
        config.cwd ??= process.cwd();
×
UNCOV
601
        config.outDir ??= s`${config.cwd}/out`;
×
602
        config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`;
603
        config.componentLibrariesPort ??= 8080;
9✔
604
        config.packagePort ??= 80;
9✔
605
        config.remotePort ??= 8060;
606
        config.sceneGraphDebugCommandsPort ??= 8080;
607
        config.controlPort ??= 8081;
608
        config.brightScriptConsolePort ??= 8085;
609
        config.stagingDir ??= config.stagingFolderPath;
9✔
610
        config.emitChannelPublishedEvent ??= true;
9✔
611
        config.rewriteDevicePathsInLogs ??= true;
612
        config.autoResolveVirtualVariables ??= false;
613
        config.enhanceREPLCompletions ??= true;
614
        config.username ??= 'rokudev';
615
        if (config.profiling?.tracing?.enable) {
616
            config.profiling.tracing.dir ??= s`${config.cwd}/traces/`;
617
            // eslint-disable-next-line no-template-curly-in-string
9✔
618
            config.profiling.tracing.filename ??= '${appTitle}_${timestamp}.perfetto-trace';
9✔
619
        }
620

621
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
9✔
622
        if (typeof config.enableVariablesPanel !== 'boolean') {
9✔
623
            config.enableVariablesPanel = true;
624
        }
625
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
UNCOV
626
        return config;
×
UNCOV
627
    }
×
UNCOV
628

×
629
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
UNCOV
630
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
×
UNCOV
631

×
UNCOV
632
        try {
×
633
            this.resetSessionState();
634
            this.launchConfiguration = this.normalizeLaunchConfig(config);
UNCOV
635
            this.setupProcessErrorHandlers();
×
636

637
            //prebake some threads for our ProjectManager to use later on (1 for the main project, and 1 for every complib)
638
            bscProjectWorkerPool.preload(1 + (this.launchConfiguration?.componentLibraries?.length ?? 0));
9✔
639

640
            //set the logLevel provided by the launch config
641
            if (this.launchConfiguration.logLevel) {
642
                logger.logLevel = this.launchConfiguration.logLevel;
4✔
643
            }
4✔
644

645
            this.sendLaunchProgress('start', 'Finding device on network');
4✔
646

4✔
647
            //do a DNS lookup for the host to fix issues with roku rejecting ECP
648
            try {
4✔
649
                this.launchConfiguration.host = await util.dnsLookup(this.launchConfiguration.host);
650
            } catch (e) {
4✔
651
                return this.shutdown(`Could not resolve ip address for host '${this.launchConfiguration.host}'`);
652
            }
4!
UNCOV
653

×
UNCOV
654
            // fetch device info if not supplied via launch config
×
655
            try {
656
                if (this.launchConfiguration.deviceInfo) {
657
                    this.deviceInfo = rokuDeploy.enhanceDeviceInfo(this.launchConfiguration.deviceInfo);
658
                } else {
4✔
UNCOV
659
                    this.deviceInfo = await rokuDeploy.getDeviceInfo({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
×
660
                }
661
                if (this.deviceInfo.ecpSettingMode === 'limited') {
662
                    return await this.shutdown(`Please ensure 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive" on your Roku device to allow the debugger to communicate properly. Current mode: Limited (device: ${this.launchConfiguration.host})`);
4✔
UNCOV
663
                }
×
UNCOV
664
            } catch (e) {
×
665
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
666
                    return this.shutdown(`Please ensure 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive" on your Roku device to allow the debugger to communicate properly. Current mode: Disabled (device: ${this.launchConfiguration.host})`);
×
UNCOV
667
                }
×
668
                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.`);
×
UNCOV
669
            }
×
670

671
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
672
                return await this.shutdown(`Developer mode is not enabled for host '${this.launchConfiguration.host}'.`);
673
            }
4✔
UNCOV
674

×
675
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
676
            this.sendResponse(response);
677

4!
UNCOV
678
            //initialize all file logging (rokuDevice, debugger, etc)
×
UNCOV
679
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
×
680

681
            this.projectManager.launchConfiguration = this.launchConfiguration;
UNCOV
682
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
×
683

684
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
685

686
            this.logger.log('[launchRequest] Packaging and deploying to roku');
4✔
UNCOV
687
            const packageEnd = this.logger.timeStart('log', 'Packaging');
×
688
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
689
            //build the main project and all component libraries at the same time
690
            await Promise.all([
691
                this.prepareMainProject(),
4✔
UNCOV
692
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
×
UNCOV
693
            ]);
×
UNCOV
694

×
UNCOV
695
            //all of the projects have been successfully staged.
×
UNCOV
696
            this.stagingDefered.tryResolve();
×
UNCOV
697

×
UNCOV
698
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
×
699
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
700
                await this.sendCustomRequest('processStagingDir', {
UNCOV
701
                    projects: this.projectManager.getProjectStagingInfo()
×
UNCOV
702
                });
×
UNCOV
703
            }
×
UNCOV
704

×
UNCOV
705
            packageEnd();
×
UNCOV
706

×
707
            if (this.enableDebugProtocol) {
708
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.launchConfiguration.host}:${this.launchConfiguration.controlPort}`);
709
            } else {
710
                util.log(`Connecting to Roku via telnet at ${this.launchConfiguration.host}:${this.launchConfiguration.brightScriptConsolePort}`);
4✔
711
            }
712

713
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
714
            try {
4✔
715
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
716
                await this.initRendezvousTracking();
717
                rendezvousEnd();
718
            } catch (e) {
719
                this.logger.error('Failed to initialize rendezvous tracking', e);
720
            }
4✔
721

722
            this.sendLaunchProgress('update', 'Connecting to debug server');
4✔
723
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
724
            this.createRokuAdapter(this.rendezvousTracker);
725

726
            // Manipulating complibs on-device autolaunches the dev app and often triggers compile errors,
4✔
727
            // so if we have at least one installable complib, delete the dev app and any complibs to avoid all that.
4✔
728
            if (this.launchConfiguration.componentLibraries?.some(x => x.install)) {
729
                this.sendLaunchProgress('update', 'Removing existing dev app and component libraries');
3!
730
                this.logger.log('Deleting any installed channel on the device to ensure a clean slate for component library installation');
3✔
731
                await rokuDeploy.deleteInstalledChannel({
732
                    host: this.launchConfiguration.host,
733
                    password: this.launchConfiguration.password
3✔
734
                });
3✔
735
                this.logger.log('Deleting any installed component libraries on the device to ensure a clean slate for component library installation');
3!
NEW
736
                await this.deleteAllComponentLibraries();
×
737
            }
738

3!
739
            await this.connectRokuAdapter();
3!
740
            connectAdapterEnd();
3✔
741

3✔
742
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
743
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
UNCOV
744
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
×
UNCOV
745
            // effect dynamically.
×
UNCOV
746
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
×
UNCOV
747
            this.sendEvent(new CapabilitiesEvent({
×
748
                supportsLogPoints: !this.enableDebugProtocol,
749
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
750
                supportsExceptionOptions: supportsExceptionBreakpoints,
751
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
752
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
UNCOV
753
            }));
×
754

755
            this.sendLaunchProgress('update', 'Configuring breakpoints');
756

3!
757
            util.log('Done initializing');
UNCOV
758

×
759
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
UNCOV
760
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
×
761
            this.sendEvent(new InitializedEvent());
762

763
            await this.initializeProfiling();
UNCOV
764

×
765
        } catch (e) {
UNCOV
766
            //if the message is anything other than compile errors, we want to display the error
×
767
            if (!(e instanceof CompileError)) {
768
                util.log('Encountered an issue during the launch process');
769
                util.log((e as Error)?.stack);
770

771
                //send any compile errors to the client
1!
772
                await this.rokuAdapter?.sendErrors();
×
UNCOV
773

×
774
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
775
                await this.shutdown(message as string, true);
×
UNCOV
776
            } else {
×
777
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
778
            }
779
        }
780
        logEnd();
1✔
781
    }
782

783
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
784
        this.logger.log('configurationDoneRequest');
785
        super.configurationDoneRequest(response, args);
786

787
        let error: Error;
788
        try {
789
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
790

18✔
791
            //press the home button to ensure we're at the home screen
792
            await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
793

794
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
54✔
795
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
796

797
            //pass along the console output
18✔
UNCOV
798
            if (this.launchConfiguration.consoleOutput === 'full') {
×
799
                this.rokuAdapter.on('console-output', (data) => {
800
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
801
                });
802
            } else {
18✔
UNCOV
803
                this.rokuAdapter.on('unhandled-console-output', (data) => {
×
804
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
805
                });
806
            }
807

18✔
UNCOV
808
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
×
809
                const stopDebuggerAction = 'Stop Debugger';
810
                const message = `Roku device ${this.launchConfiguration.host} is not responding and may not recover.` +
811
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
812
                this.logger.log(message, data);
813
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
18✔
814
                if (response === stopDebuggerAction) {
×
815
                    await this.shutdown();
816
                }
817
            });
818

819
            // Send chanperf events to the extension
18✔
820
            this.rokuAdapter.on('chanperf', (output) => {
2✔
821
                this.sendEvent(new ChanperfEvent(output));
2✔
822
            });
2✔
823

824
            //listen for a closed connection (shut down when received)
825
            this.rokuAdapter.on('close', (reason = '') => {
1✔
826
                if (reason === 'compileErrors') {
827
                    error = new Error('compileErrors');
828
                } else {
829
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
16✔
830
                }
2✔
831
            });
832

833
            // handle any compile errors
834
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
14✔
835
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
2!
836
            });
2✔
837

2✔
838
            // close disconnect if required when the app is exited
2✔
839
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
840
            this.rokuAdapter.on('app-exit', async () => {
841
                this.resetSessionState();
842

12✔
843
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
844
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
845
                    message += ' - shutting down debug session';
846

847
                    this.logger.log('on app-exit', message);
848
                    this.sendEvent(new LogOutputEvent(message));
849
                    await this.shutdown();
850
                } else {
8✔
851
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
2✔
852
                    this.logger.log('[configurationDoneRequest]', message);
2✔
853
                    this.sendEvent(new LogOutputEvent(message));
854
                    this.rokuAdapter.once('connected').then(async () => {
855
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
1✔
856
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
857
                }
858
            });
6✔
859
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
1!
860
            await this.tryProfilingConnectOnStart();
1✔
861

1✔
862
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
1✔
863
            //that the client has finished sending configuration). Inject the STOPs and postfix each project's
864
            //own files now (still no zips. those are sealed after cross-project references are rewritten).
865
            await Promise.all([
866
                this.writeMainProjectBreakpoints(),
867
                this.writeAndPostfixComponentLibraries(this.launchConfiguration.componentLibraries)
868
            ]);
869

870
            //now that EVERY project (main + all complibs) is postfixed, rewrite cross-project `Library`
871
            //references to point at the postfixed file names. Must run before any project zip is sealed.
10✔
872
            await this.projectManager.applyLibraryReferencePostfixes();
873

874
            //now zip the main project, also zip and upload installable complibs, and start the webserver for non-installed complibs.
10✔
875
            await Promise.all([
876
                this.zipMainProject(),
877
                this.zipServeAndInstallComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
878
            ]);
879

880
            this.sendLaunchProgress('update', 'Uploading to Roku');
3✔
881
            await this.publish();
3✔
882

3✔
883
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
884
            if (this.launchConfiguration.emitChannelPublishedEvent) {
885
                this.sendEvent(new ChannelPublishedEvent(
3✔
886
                    this.launchConfiguration
887
                ));
888
            }
3!
UNCOV
889

×
UNCOV
890
            //tell the adapter adapter that the channel has been launched.
×
891
            this.sendLaunchProgress('update', 'Waiting on application');
892
            await this.rokuAdapter.activate();
893
            if (this.rokuAdapter.isDestroyed) {
894
                throw new Error('Debug session encountered an error');
895
            }
3✔
896
            if (!error) {
897
                if (this.rokuAdapter.connected) {
3✔
UNCOV
898
                    this.logger.info('Host connection was established before the main public process was completed');
×
899
                    this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
900
                } else {
901
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
3✔
902
                    this.rokuAdapter.on('connected', (status) => {
1✔
903
                        if (status) {
904
                            this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
905
                        }
3✔
906
                    });
907
                }
3✔
908
            } else {
909
                throw error;
2✔
910
            }
911

912
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
913
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
914
                //wait until the first entry breakpoint has been hit
915
                await this.firstRunDeferred.promise;
916
                //if we are at a breakpoint, continue
917
                await this.rokuAdapter.continue();
2✔
918
                //kill the app on the roku
2✔
919
                // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
920
                //convert a hostname to an ip address
2✔
921
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
1✔
922
                //send the deep link http request
1✔
923
                await util.httpPost(deepLinkUrl);
1✔
924
            }
925

926
        } catch (e) {
927
            //if the message is anything other than compile errors, we want to display the error
1✔
928
            if (!(e instanceof CompileError)) {
929
                util.log('Encountered an issue during the publish process');
930
                util.log((e as Error)?.stack);
931

2✔
932
                //send any compile errors to the client
2✔
933
                await this.rokuAdapter?.sendErrors();
1✔
934

1✔
935
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
936
                await this.shutdown(message as string, true);
2✔
937
            } else {
938
                this.sendLaunchProgress('end', 'Aborted (compile error)');
939
            }
940
        }
2✔
941
    }
2✔
942

943
    /**
2✔
944
     * Activate all required functionality for profiling
2!
UNCOV
945
     */
×
946
    private async initializeProfiling() {
947

948
        // Initialize PerfettoManager
949
        this.perfettoManager = new PerfettoManager({
950
            host: this.launchConfiguration.host,
UNCOV
951
            rootDir: this.launchConfiguration.rootDir,
×
UNCOV
952
            remotePort: this.launchConfiguration.remotePort,
×
UNCOV
953
            ...this.launchConfiguration.profiling?.tracing
×
UNCOV
954
        });
×
UNCOV
955

×
956
        //send certain profiling events back to the client
UNCOV
957
        this.perfettoManager.on('enable', (event) => {
×
958
            this.sendEvent(new ProfilingEnableEvent({
959
                types: event.types
2✔
960
            }));
2✔
961
        });
962
        this.perfettoManager.on('start', (event) => {
963
            this.sendEvent(new ProfilingStartEvent({
964
                type: event.type
965
            }));
966
        });
967
        this.perfettoManager.on('stop', (event) => {
968
            this.sendEvent(new ProfilingStopEvent({
969
                type: event.type,
970
                result: event.result
971
            }));
972
        });
973
        this.perfettoManager.on('error', (event) => {
974
            this.sendEvent(new ProfilingErrorEvent({
2✔
975
                error: event.error
1✔
976
            }));
1✔
977
        });
978

979
        //tracing is explicitly enabled. Turn it on
2✔
980
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
2✔
981
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
982
            try {
UNCOV
983
                await this.perfettoManager.enableTracing();
×
UNCOV
984
            } catch (e) {
×
UNCOV
985
                this.logger.error('Failed to enable perfetto tracing', e);
×
UNCOV
986
            }
×
UNCOV
987

×
988
            //tracing is expicitly DISabled. turn it off
UNCOV
989
        } else if (this.launchConfiguration.profiling?.tracing?.enable === false && this.supportsPerfettoTracing) {
×
990
            this.logger.info('Disabling perfetto tracing because it is disabled in the launch configuration');
991
            //TODO implement a way to disable perfetto tracing on the device
2✔
992

2✔
993
            //tracing was requested but the device firmware does not meet the minimum requirement
994
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
995
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
2✔
996
            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✔
997
            this.logger.warn(message);
998
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
999

2✔
1000
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
1001
        } else {
1002
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
2✔
1003
        }
2✔
1004
    }
1✔
1005

1006
    /**
1007
     * If profiling was marked "connectOnStart", try connecting right away
2✔
1008
     */
1✔
1009
    private async tryProfilingConnectOnStart() {
1010
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
1011
            try {
1012
                await this.perfettoManager.startTracing();
1013
            } catch (e) {
1014
                this.logger.error('Failed to start perfetto tracing on start', e);
1015
            }
1016
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
38!
UNCOV
1017
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
×
1018
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1019
            this.logger.warn(message);
38✔
1020
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
38✔
1021
        }
38✔
1022
    }
38✔
1023

38✔
1024
    /**
264✔
1025
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
264✔
1026
     */
226✔
1027
    private resetSessionState() {
1028
        // launchRequest gets invoked by our restart session flow.
264✔
1029
        // We need to clear/reset some state to avoid issues.
91✔
1030
        this.entryBreakpointWasHandled = false;
91✔
1031
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
28✔
1032
        //re-stages the project
28✔
1033
        this.breakpointManager.reset();
1034
    }
26✔
1035

26✔
1036
    /**
6✔
1037
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
3✔
1038
     */
1039
    public async initRendezvousTracking() {
1040
        const timeout = 5000;
3✔
1041
        let initCompleted = false;
1042
        await Promise.race([
1043
            util.sleep(timeout),
26✔
1044
            this._initRendezvousTracking().finally(() => {
26✔
1045
                initCompleted = true;
10✔
1046
            })
1047
        ]);
26✔
1048

1049
        if (initCompleted === false) {
1050
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
1051
                this.logger.error('Error showing popup message', { error });
264✔
1052
            });
264✔
1053
        }
1054
    }
1055

38✔
1056
    private async _initRendezvousTracking() {
1057
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
1058

1059
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
1060
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
1061
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
1062
        });
1063

1064
        // Send rendezvous events to the debug protocol client
1065
        this.rendezvousTracker.on('rendezvous', (output) => {
1066
            this.sendEvent(new RendezvousEvent(output));
1067
        });
1068

1069
        //clear the history so the user doesn't have leftover rendezvous data from a previous session
1070
        this.rendezvousTracker.clearHistory();
1071

1072
        //if rendezvous tracking is enabled, then enable it on the device
1073
        if (this.launchConfiguration.rendezvousTracking !== false) {
91✔
1074
            // start ECP rendezvous tracking (if possible)
91✔
1075
            await this.rendezvousTracker.activate();
91!
1076
        }
91✔
1077
    }
28✔
1078

28✔
1079
    /**
28✔
1080
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
28✔
1081
     */
28✔
1082
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
17✔
1083
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1084
        for (let diagnostic of diagnostics) {
28✔
1085
            diagnostic.source = diagnosticSource;
1086
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
1087
            if (sourceLocation) {
1088
                diagnostic.path = sourceLocation.filePath;
1089
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1090
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1091
            } else {
1092
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
91✔
1093
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1094
            }
1095
        }
1096

1097
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1098
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
38✔
1099
        if (this.compileError) {
8✔
1100
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1101
            this.sendEvent(new StoppedEvent(
1102
                StoppedEventReason.exception,
1103
                this.COMPILE_ERROR_THREAD_ID,
1104
                `CompileError: ${this.compileError.message}`
30✔
1105
            ));
30✔
1106
        }
30✔
1107

30✔
1108
        this.sendEvent(new DiagnosticsEvent(diagnostics));
5✔
1109
    }
5✔
1110

1111
    private publishTimeout = 60_000;
30!
1112

30✔
1113
    private async publish() {
5✔
1114
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
5✔
1115
        let packageIsPublished = false;
5✔
1116

5✔
1117
        //delete any currently installed dev channel (if enabled to do so)
5✔
1118
        try {
5✔
1119
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
1120
                await this.rokuDeploy.deleteInstalledChannel({
4✔
1121
                    ...this.launchConfiguration
4✔
1122
                } as any as RokuDeployOptions);
2✔
1123
            }
1✔
1124
        } catch (e) {
1125
            const statusCode = e?.results?.response?.statusCode;
1126
            const message = e.message as string;
1✔
1127
            if (statusCode === 401) {
1128
                await this.shutdown(message, true);
1129
                throw e;
4✔
1130
            }
4✔
1131
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
1132
        }
4✔
1133

4✔
1134
        const isConnected = this.rokuAdapter.once('app-ready');
4✔
1135
        const options: RokuDeployOptions = {
1136
            ...this.launchConfiguration,
1137
            //typing fix
1138
            logLevel: LogLevelPriority[this.logger.logLevel],
30✔
1139
            // enable the debug protocol if true
1140
            remoteDebug: this.enableDebugProtocol,
1141
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1!
UNCOV
1142
            remoteDebugConnectEarly: false,
×
UNCOV
1143
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
×
UNCOV
1144
            failOnCompileError: true,
×
UNCOV
1145
            //pass any upload form overrides the client may have configured
×
1146
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
UNCOV
1147
        };
×
1148
        //if packagePath is specified, use that info instead of outDir and outFile
UNCOV
1149
        if (this.launchConfiguration.packagePath) {
×
UNCOV
1150
            options.outDir = path.dirname(this.launchConfiguration.packagePath);
×
UNCOV
1151
            options.outFile = path.basename(this.launchConfiguration.packagePath);
×
UNCOV
1152
        }
×
1153

UNCOV
1154
        //publish the package to the target Roku
×
1155
        const publishPromise = this.rokuDeploy.publish(options).then(() => {
UNCOV
1156
            packageIsPublished = true;
×
UNCOV
1157
        }).catch(async (e) => {
×
1158
            const statusCode = e?.results?.response?.statusCode;
×
1159
            const message = e.message as string;
×
1160
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
1161
                await this.shutdown(message, true);
×
1162
                throw e;
UNCOV
1163
            }
×
1164
            this.logger.error(e);
×
UNCOV
1165
        });
×
UNCOV
1166

×
1167
        await publishPromise;
UNCOV
1168

×
1169
        uploadingEnd();
UNCOV
1170

×
UNCOV
1171
        //the channel has been deployed. Wait for the adapter to finish connecting.
×
UNCOV
1172
        //if it hasn't connected after 60 seconds, abort the launch.
×
UNCOV
1173
        let didTimeOut = false;
×
1174
        await Promise.race([
UNCOV
1175
            isConnected,
×
1176
            util.sleep(this.publishTimeout).then(() => {
1177
                didTimeOut = true;
UNCOV
1178
            })
×
1179
        ]);
1180
        this.logger.log('Finished racing promises');
UNCOV
1181
        if (didTimeOut) {
×
1182
            this.logger.warn('Timed out waiting for roku to connect');
1183
        }
1184
        //if the adapter is still not connected, then it will probably never connect. Abort.
1185
        if (packageIsPublished && !this.rokuAdapter.connected) {
1186
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1187
        }
1188
    }
1189

1190
    private pendingSendLogPromise = Promise.resolve();
2✔
1191

1192
    /**
1193
     * Send log output to the "client" (i.e. vscode)
1194
     * @param logOutput
1195
     */
1196
    private sendLogOutput(logOutput: string) {
1197
        if (this.isCrashed) {
1198
            return Promise.resolve();
1199
        }
1200
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
1201

1202
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
1203
            logOutput = await this.convertBacktracePaths(logOutput);
1204

2✔
1205
            const lines = logOutput.split(/\r?\n/g);
2✔
1206
            for (let i = 0; i < lines.length; i++) {
1207
                let line = lines[i];
2✔
1208
                if (i < lines.length - 1) {
1209
                    line += '\n';
1210
                }
1211

1212
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
1213
                    let potentialPaths = this.getPotentialPkgPaths(line);
1214
                    for (let potentialPath of potentialPaths) {
1215
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
1216
                        if (originalLocation) {
1217
                            let replacement: string;
2✔
1218
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
1219
                            if (replacement !== originalLocation.filePath) {
2✔
1220
                                if (this.isWindowsPlatform) {
1221
                                    replacement = `vscode://file/${replacement}`;
1222
                                } else {
1223
                                    replacement = `file://${replacement}`;
1224
                                }
1225
                            }
1226
                            replacement += `:${originalLocation.lineNumber}`;
1227
                            if (potentialPath.columnNumber !== undefined) {
2✔
1228
                                replacement += `:${originalLocation.columnIndex + 1}`;
1✔
1229
                            }
1✔
1230

1✔
1231
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
1232
                        }
1233
                    }
1234
                }
1!
1235
                this.sendEvent(new OutputEvent(line, 'stdout'));
1✔
1236
                this.sendEvent(new LogOutputEvent(line));
1✔
1237
            }
1238
        });
1!
1239
        return this.pendingSendLogPromise;
1!
UNCOV
1240
    }
×
1241

1242
    /**
1243
     * Extracts potential package paths from a given line of text.
1244
     *
1245
     * This method uses a regular expression to find matches in the provided line
1✔
1246
     * and returns an array of objects containing details about each match.
1✔
1247
     *
1248
     * @param input - The line of text to search for potential package paths.
1249
     * @returns An array of objects, each containing:
1250
     *   - `fullMatch`: The full matched string.
1251
     *   - `path`: The extracted path from the match.
1252
     *   - `lineNumber`: The line number extracted from the match.
1253
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
UNCOV
1254
     */
×
UNCOV
1255
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
×
1256
        // https://regex101.com/r/ixpQiq/1
UNCOV
1257
        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);
×
UNCOV
1258
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
×
1259
        if (matches) {
UNCOV
1260
            for (let match of matches) {
×
UNCOV
1261
                let fullMatch = match[0];
×
1262
                let path = match[1];
UNCOV
1263
                let lineNumber = parseInt(match[2] ?? match[4]);
×
UNCOV
1264
                let columnNumber = parseInt(match[3] ?? match[5]);
×
1265
                if (isNaN(columnNumber)) {
UNCOV
1266
                    columnNumber = undefined;
×
UNCOV
1267
                }
×
1268
                paths.push({
UNCOV
1269
                    fullMatch: fullMatch,
×
UNCOV
1270
                    path: path,
×
UNCOV
1271
                    lineNumber: lineNumber,
×
1272
                    columnNumber: columnNumber
1273
                });
UNCOV
1274
            }
×
UNCOV
1275
        }
×
1276
        return paths;
1277
    }
UNCOV
1278

×
UNCOV
1279
    /**
×
UNCOV
1280
     * Converts the filename property in backtrace objects in the given input string to source paths if found
×
1281
     */
1282
    private async convertBacktracePaths(input: string) {
UNCOV
1283
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
×
UNCOV
1284
            return input;
×
1285
        }
1286
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
UNCOV
1287
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
×
1288

1289
        // https://regex101.com/r/y1koaV/2
1290
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
1291
        let matches = [];
1292
        let match = deviceBacktraceObjectRegex.exec(input);
1293
        while (match) {
11✔
1294
            matches.push(match);
2✔
1295
            match = deviceBacktraceObjectRegex.exec(input);
1296
        }
9✔
1297

1298
        if (matches) {
9✔
1299
            for (let match of matches) {
9✔
1300
                let fullMatch = match[0] as string;
1301
                let filePath = match[1] as string;
9✔
1302
                let fullLineNumber = match[2] as string;
19✔
1303
                let lineNumber = parseInt(match[3] as string);
19✔
1304
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
1305
                if (originalLocation) {
1306
                    let fileReplacement: string;
1307
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
1308
                    if (fileReplacement !== originalLocation.filePath) {
1309
                        if (this.isWindowsPlatform) {
1310
                            fileReplacement = `vscode://file/${fileReplacement}`;
1311
                        } else {
1312
                            fileReplacement = `file://${fileReplacement}`;
1313
                        }
1314
                    }
1315
                    fileReplacement += `:${originalLocation.lineNumber}`;
1316

1317
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
1318

1319
                    // 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
19✔
1320
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
1321
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
1322
                    input = input.replaceAll(fullMatch, completeReplacement);
1323
                }
1324

1325
            }
1326
        }
1327

1328
        return input;
10✔
1329
    }
2✔
1330

1331
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1332
        if (commands) {
8✔
1333
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.host, this.launchConfiguration.sceneGraphDebugCommandsPort);
1334

8✔
1335
            try {
18✔
1336
                await connection.connect();
18✔
1337
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
1338
                    let response: SceneGraphCommandResponse;
1339
                    switch (command) {
1340
                        case 'chanperf':
1341
                            util.log('Enabling Chanperf Tracking');
1342
                            response = await connection.chanperf({ interval: 1 });
1343
                            if (!response.error) {
1344
                                util.log(response.result.rawResponse);
1345
                            }
10✔
1346
                            break;
2✔
1347

1348
                        case 'fpsdisplay':
8✔
1349
                            util.log('Enabling FPS Display');
9✔
1350
                            response = await connection.fpsDisplay('on');
8✔
1351
                            if (!response.error) {
1352
                                util.log(response.result.data as string);
1353
                            }
1354
                            break;
7✔
1355

1356
                        case 'logrendezvous':
1357
                            util.log('Enabling Rendezvous Logging:');
18✔
1358
                            response = await connection.logrendezvous('on');
1359
                            if (!response.error) {
1360
                                util.log(response.result.rawResponse);
1361
                            }
1362
                            break;
8✔
1363

18✔
1364
                        default:
18✔
1365
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
1366
                            response = await connection.exec(command);
12✔
1367
                            if (!response.error) {
12✔
1368
                                util.log(response.result.rawResponse);
2✔
1369
                            }
1370
                            break;
12✔
1371
                    }
1372
                }
1373
                await connection.end();
24✔
1374
            } catch (error) {
1375
                util.log(`Error connecting to port 8080: ${error.message}`);
1376
            }
1377
        }
1378
    }
1379

22✔
1380
    /**
1381
     * Stage, insert breakpoints, and package the main project
12✔
1382
     */
2✔
1383
    public async prepareMainProject() {
2✔
1384
        //add the main project
1385
        this.projectManager.mainProject = new Project({
12✔
1386
            rootDir: this.launchConfiguration.rootDir,
12✔
1387
            files: this.launchConfiguration.files,
12✔
1388
            outDir: this.launchConfiguration.outDir,
11✔
1389
            sourceDirs: this.launchConfiguration.sourceDirs,
1390
            bsConst: this.launchConfiguration.bsConst,
1391
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1392
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1393
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1✔
1394
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1✔
1395
            stagingDir: this.launchConfiguration.stagingDir,
1396
            packagePath: this.launchConfiguration.packagePath,
1397
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1398
        });
7✔
UNCOV
1399

×
1400
        util.log('Moving selected files to staging area');
1401
        await this.projectManager.mainProject.stage();
1402

7✔
1403
        //add the entry breakpoint if stopOnEntry is true
1404
        await this.handleEntryBreakpoint();
1405
    }
1406

1407
    /**
1408
     * Inject breakpoint STOP statements into the staged main project. Runs after the DAP `InitializedEvent`
1409
     * so client-side `setBreakpoints` requests have landed before any STOPs are written to the staged .brs
1410
     * files (telnet path). Kept separate from zipping so the cross-project `Library` reference rewrite can
1411
     * run in between (after every project is postfixed, before any zip is sealed).
1412
     */
1413
    private async writeMainProjectBreakpoints() {
1414
        //add breakpoint lines to source files and then publish
1415
        util.log('Adding stop statements for active breakpoints');
1416

1417
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1418
        await this.breakpointManager.validateAndWriteBreakpointsForProject(this.projectManager.mainProject);
1419
    }
1420

1421
    /**
16✔
1422
     * Create the main project's zip package (or run the configured packageTask). Must run AFTER
1423
     * `applyLibraryReferencePostfixes` so any rewritten `Library` statements are included in the zip.
1424
     */
32✔
1425
    private async zipMainProject() {
1426
        if (this.launchConfiguration.packageTask) {
16✔
1427
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1428
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
39✔
1429

40✔
1430
            const options = {
1431
                ...this.launchConfiguration
1432
            } as any as RokuDeployOptions;
1433
            //if packagePath is specified, use that info instead of outDir and outFile
16✔
1434
            if (this.launchConfiguration.packagePath) {
28✔
1435
                options.outDir = path.dirname(this.launchConfiguration.packagePath);
1436
                options.outFile = path.basename(this.launchConfiguration.packagePath);
1437
            }
16✔
1438
            const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options);
24✔
1439

24✔
1440
            if (!fsExtra.pathExistsSync(packagePath as string)) {
1441
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
16✔
1442
            }
1443
        } else {
16✔
1444
            //create zip package from staging folder
16✔
1445
            util.log('Creating zip archive from project sources');
16✔
1446
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1447
        }
39✔
1448
    }
39✔
1449

1450
    /**
1451
     * Accepts custom events and requests from the extension
1452
     * @param command name of the command to execute
40✔
1453
     */
39✔
1454
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
1455
        if (command === 'rendezvous.clearHistory') {
1456
            this.rokuAdapter.clearRendezvousHistory();
39✔
1457
        } else if (command === 'chanperf.clearHistory') {
15✔
1458
            this.rokuAdapter.clearChanperfHistory();
1✔
1459

1460
        } else if (command === 'customRequestEventResponse') {
14✔
1461
            this.emit('customRequestEventResponse', args);
1462

24✔
1463
        } else if (command === 'popupMessageEventResponse') {
24✔
1464
            this.emit('popupMessageEventResponse', args);
24✔
1465

24✔
1466
        } else if (command === 'captureHeapSnapshot') {
1467
            this.perfettoManager.captureHeapSnapshot().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
1468

1469
        } else if (command === 'startPerfettoTracing') {
9✔
1470
            try {
1✔
1471
                await this.perfettoManager.startTracing();
1472
            } catch (e) {
1473
                response.success = false;
1474
                response.body = { message: e?.message || String(e) };
1475
            }
8!
UNCOV
1476

×
1477
        } else if (command === 'stopPerfettoTracing') {
1478
            try {
1479
                await this.perfettoManager.stopTracing();
1480
            } catch (e) {
1481
                response.success = false;
8✔
1482
                response.body = { message: e?.message || String(e) };
1✔
1483
            }
1484

8✔
1485
        }
1486
        this.sendResponse(response);
1487
    }
1488

23✔
1489
    /**
1490
     * Stores the path to the staging folder for each component library
1491
     */
1492
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1493
        if (!componentLibraries || componentLibraries.length === 0) {
1494
            return;
1495
        }
1496
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
1497
        //make sure this folder exists (and is empty)
9!
1498
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1499
        await fsExtra.emptyDir(componentLibrariesOutDir);
1500

1501
        //create a ComponentLibraryProject for each component library
1502
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
1503
            let componentLibrary = componentLibraries[libraryIndex];
1504

1505
            this.projectManager.componentLibraryProjects.push(
1506
                new ComponentLibraryProject({
1507
                    rootDir: componentLibrary.rootDir,
1508
                    files: componentLibrary.files,
1509
                    outDir: componentLibrariesOutDir,
8!
1510
                    outFile: componentLibrary.outFile,
8✔
1511
                    sourceDirs: componentLibrary.sourceDirs,
1512
                    bsConst: componentLibrary.bsConst,
UNCOV
1513
                    install: componentLibrary.install,
×
UNCOV
1514
                    enablePostfix: componentLibrary.enablePostfix,
×
UNCOV
1515
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
×
UNCOV
1516
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
×
UNCOV
1517
                    libraryIndex: libraryIndex,
×
1518
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
UNCOV
1519
                })
×
1520
            );
1521
        }
1522

1523
        //stage all of the libraries in parallel
1524
        await Promise.all(
1525
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
1526
        );
6✔
1527
    }
6✔
1528

1529
    /**
6✔
1530
     * Inject breakpoint STOPs into the staged complibs and postfix each library's own files. Runs in
6✔
1531
     * `configurationDoneRequest` (after `InitializedEvent`) so client-side `setBreakpoints` requests have
1532
     * landed before the staged .brs files are written. Kept separate from zipping so the cross-project
1533
     * `Library` reference rewrite can run after EVERY project is postfixed but before any zip is sealed.
6✔
1534
     */
1535
    protected async writeAndPostfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
6✔
1536
        if (!componentLibraries || componentLibraries.length === 0) {
6!
1537
            return;
1538
        }
UNCOV
1539

×
1540
        // Add breakpoint lines to the staging files and before publishing
1541
        util.log('Adding stop statements for active breakpoints in Component Libraries');
1542

4✔
1543
        //validate breakpoints (and write STOPs for telnet) and postfix each complib's own files in parallel
4✔
1544
        await Promise.all(
1545
            this.projectManager.componentLibraryProjects.map(async (compLibProject) => {
4!
NEW
1546
                await this.breakpointManager.validateAndWriteBreakpointsForProject(compLibProject);
×
1547
                await compLibProject.postfixFiles();
1548
            })
1549
        );
1550
    }
4✔
1551

1552
    /**
4✔
1553
     * Seal every component library's zip, install the installable ones (in order), and start static file
3✔
1554
     * hosting. Must run AFTER `applyLibraryReferencePostfixes` so each zip contains the rewritten `Library`
3✔
1555
     * statements.
4✔
1556
     */
4✔
1557
    protected async zipServeAndInstallComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1558
        if (!componentLibraries || componentLibraries.length === 0) {
3!
NEW
1559
            return;
×
1560
        }
1561
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
1562

1563
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
1564
        if (needToDeleteComplibs) {
1565
            //deleteAllComponentLibraries pauses compile-error reporting (to swallow deletion-induced errors) and
1566
            //leaves it paused. Now that deletion is done and we're about to put libraries back on the device, settle
1567
            //(draining any lingering deletion output) and resume reporting so real install errors are surfaced.
1✔
1568
            await this.deleteAllComponentLibraries();
1569
        }
1570

4✔
1571
        //zip each complib in parallel
1572
        const packagePromises = this.projectManager.componentLibraryProjects.map(
1573
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
4✔
1574
        );
1575

1576
        //install component libraries strictly in their declared order (which must be dependency order:
1577
        //a library may only depend on libraries declared before it). Each install is fully awaited before the
1578
        //next begins, and a failure aborts the launch. Installing out of order, or continuing past a failed
1579
        //install, leaves the device with dangling `Library` references that fail to compile.
1580
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
1581
            const compLibProject = this.projectManager.componentLibraryProjects[i];
1582

24✔
1583
            if (compLibProject.install === true) {
24✔
1584
                //wait for this complib to finish being packaged
1585
                await packagePromises[i];
13✔
1586

13✔
1587
                if (componentLibraries[i].packageTask) {
10✔
1588
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
1589
                }
13✔
1590

9✔
1591
                const options: RokuDeployOptions = {
1592
                    host: this.launchConfiguration.host,
13✔
1593
                    password: this.launchConfiguration.password,
9✔
1594
                    username: this.launchConfiguration.username || 'rokudev',
1595
                    logLevel: LogLevelPriority[this.logger.logLevel],
13✔
1596
                    failOnCompileError: true,
1597
                    outDir: compLibProject.outDir,
1598
                    outFile: compLibProject.outFile,
24✔
1599
                    appType: 'dcl',
24✔
1600
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
11✔
1601
                };
1602

24✔
1603
                if (componentLibraries[i].packagePath) {
4✔
1604
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
1605
                    options.outFile = path.basename(componentLibraries[i].packagePath);
1606
                }
24✔
1607

24✔
1608
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
1609
                try {
1610
                    await rokuDeploy.publish(options);
1611
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
3✔
1612
                } catch (error) {
3✔
1613
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
3✔
1614
                    //here means later libraries and the main app would reference a library that isn't on the device
1615
                    this.logger.error(`Error installing component library ${i} (${compLibProject.outFile})`, error);
3!
NEW
1616
                    throw error;
×
1617
                }
1618
            }
1619
        }
1620

3!
UNCOV
1621
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
×
1622
            util.log(message);
×
UNCOV
1623
        });
×
1624

1625
        //wait for all complib packaging to finish and the file hosting to start
1626
        await Promise.all([
1627
            ...packagePromises,
1628
            hostingPromise
3✔
1629
        ]);
3!
1630
    }
3✔
1631

3✔
1632
    /**
1633
     * Delete every component library installed on the device.
1634
     *
2✔
1635
     * A complib can only depend on complibs the user declared BEFORE it, so the user's configured order is already
2✔
1636
     * dependency order. Deleting in the REVERSE of that order therefore deletes each complib before the ones it
2✔
1637
     * depends on, avoiding compile errors entirely. We use that reverse order as the delete priority.
1638
     *
1639
     * Anything still installed that the user did NOT configure (e.g. leftovers from a previous session) has no known
1✔
1640
     * order, so we fall back to compile-error tolerance for those: deleting a complib while another still references
3✔
1641
     * it fails with a compile error, which we ignore and retry later (after its dependent is gone). We finish when
1642
     * every complib is gone, and fail if the only complibs left are ones that can never be deleted.
1643
     */
3✔
1644
    private async deleteAllComponentLibraries() {
3!
1645
        const deviceOptions = {
3!
1646
            host: this.launchConfiguration.host,
1647
            password: this.launchConfiguration.password,
1648
            username: this.launchConfiguration.username || 'rokudev'
3!
NEW
1649
        };
×
1650

1651
        const getInstalledComplibs = async () => {
3✔
1652
            // eslint-disable-next-line @typescript-eslint/dot-notation
1653
            const packages = (await rokuDeploy['getInstalledPackages'](deviceOptions)) as Array<{ appType: 'channel' | 'dcl'; archiveFileName: string }>;
1654
            return packages.filter(x => x.appType === 'dcl');
NEW
1655
        };
×
1656

1657
        //The user's configured complibs, in REVERSE declaration order (dependents before their dependencies). A
3!
1658
        //complib's position here is its delete priority; complibs the user didn't configure aren't in this list.
3✔
1659
        const deletePriority = this.projectManager.componentLibraryProjects
1660
            .map(complib => complib.outFile)
18!
1661
            .reverse();
3!
NEW
1662
        //sort key for a complib: its index in `deletePriority`, or Infinity (delete last) if it wasn't configured
×
1663
        const priorityOf = (fileName: string) => {
1664
            const index = deletePriority.indexOf(fileName);
3✔
1665
            return index === -1 ? Infinity : index;
1666
        };
1667

1668
        const maxAttempts = 5;
NEW
1669
        //how many times we've tried to delete each complib, and the ones we've given up on after maxAttempts
×
1670
        const attempts = new Map<string, number>();
1671
        const failed = new Set<string>();
1672

3✔
1673
        while (true) {
1674
            //re-fetch the installed complibs after each iteration: deleting one complib can cascade-delete others, so never delete a stale entry
1675
            const installed = await getInstalledComplibs();
1676

3✔
1677
            const attemptCount = (complib: { archiveFileName: string }) => attempts.get(complib.archiveFileName) ?? 0;
1678

NEW
1679
            //of the complibs we haven't given up on, pick the next to delete: fewest attempts first (spreads retries
×
1680
            //evenly so a dependent gets deleted before we circle back to a complib that previously failed), and among
1681
            //equal attempts, follow the reverse-configured delete priority (dependents before dependencies).
1682
            const candidates = installed.filter(complib => !failed.has(complib.archiveFileName));
1683
            const next = candidates.sort((a, b) =>
1✔
1684
                attemptCount(a) - attemptCount(b) ||
1✔
1685
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
1✔
1686
            )[0];
1✔
1687

1688
            //nothing left to try — done if the device is clear, otherwise hard-fail with whatever remains
1✔
1689
            if (!next) {
1✔
1690
                if (installed.length > 0) {
1691
                    throw new Error(`Failed to delete existing component libraries on device; ${installed.length} still installed: ${installed.map(x => x.archiveFileName).join(', ')}`);
1692
                }
1693
                return;
1694
            }
1695

1696
            const fileName = next.archiveFileName;
1✔
NEW
1697
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
×
NEW
1698
            try {
×
NEW
1699
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
×
NEW
1700
            } catch (error) {
×
NEW
1701
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
×
NEW
1702
                if (!this.isComponentLibraryDependencyCompileError(error)) {
×
NEW
1703
                    throw error;
×
1704
                }
1705
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
NEW
1706
                //STILL have succeeded (the device reports both a compile error and 'Delete Succeeded'). If it actually
×
1707
                //deleted, treat it as done. Only if it did NOT succeed do we defer and retry it later.
NEW
1708
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
×
NEW
1709
                    this.logger.trace(`Deleted '${fileName}' (device reported a compile error but the delete succeeded)`);
×
1710
                } else {
1711
                    //another not-yet-deleted complib still depends on this one. Give up on it once it's out of
1712
                    //attempts; otherwise leave it pending so we retry after its dependents are gone.
1713
                    if (attempts.get(fileName) >= maxAttempts) {
NEW
1714
                        failed.add(fileName);
×
1715
                    }
1716
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
1717
                }
1718
            }
1719

1720
            //the Roku doesn't appreciate back-to-back deletes; give it a moment between requests
1721
            await util.sleep(10);
NEW
1722
        }
×
1723
    }
1724

NEW
1725
    /**
×
NEW
1726
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
×
NEW
1727
     * installed complib still references the one we tried to delete)?
×
1728
     */
1729
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1730
        const message = `${error?.message ?? ''}`;
1✔
1731
        return /compile error|compilation failed/i.test(message);
1732
    }
1733

1734
    /**
1735
     * Did the component-library delete actually succeed, even though the device also reported a compile error?
1736
     * When we delete a complib that a dependent still references, the device reports BOTH a compile error AND a
1737
     * `Delete Succeeded` message - meaning the complib really was removed. roku-deploy attaches the parsed device
1738
     * messages (`{ errors, infos, successes }`) to the thrown error as `.results`; we look for the success there.
5✔
1739
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
5✔
1740
     */
1✔
1741
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
1742
        const successes: string[] = error?.results?.successes ?? [];
1743
        return successes.some(message => /delete succeeded/i.test(message));
1744
    }
1745

1746
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
1747
        this.logger.log('sourceRequest');
1748
        let old = this.sendResponse;
1749
        this.sendResponse = function sendResponse(...args) {
1750
            old.apply(this, args);
5✔
1751
            this.sendResponse = old;
1752
        };
1753
        super.sourceRequest(response, args);
UNCOV
1754
    }
×
UNCOV
1755

×
UNCOV
1756
    /**
×
UNCOV
1757
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
×
1758
     */
UNCOV
1759
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
×
UNCOV
1760
        this.logger.log('setBreakpointsRequest', args);
×
UNCOV
1761
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
×
1762
        //sort the breakpoints
UNCOV
1763
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
×
UNCOV
1764

×
1765
        response.body = {
1766
            breakpoints: sortedAndFilteredBreakpoints
UNCOV
1767
        };
×
1768
        this.sendResponse(response);
UNCOV
1769

×
UNCOV
1770
        //ensure we've staged all the files
×
UNCOV
1771
        await this.stagingDefered.promise;
×
UNCOV
1772

×
1773
        await this.rokuAdapter?.syncBreakpoints();
UNCOV
1774
    }
×
UNCOV
1775

×
1776
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
1777
        this.logger.log('exceptionInfoRequest');
UNCOV
1778
    }
×
UNCOV
1779

×
1780
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1781
        this.logger.log('threadsRequest');
1782

1783
        let threads = [];
1784

1785
        //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
1786
        if (this.compileError) {
1787
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1788
        } else {
UNCOV
1789
            //wait for the roku adapter to load
×
UNCOV
1790
            await this.getRokuAdapter();
×
UNCOV
1791

×
UNCOV
1792
            //only send the threads request if we are at the debugger prompt
×
1793
            if (this.rokuAdapter.isAtDebuggerPrompt) {
UNCOV
1794
                let rokuThreads = await this.rokuAdapter.getThreads();
×
UNCOV
1795

×
1796
                for (let thread of rokuThreads) {
UNCOV
1797
                    const threadName = this.getThreadName(thread as AdapterThread);
×
UNCOV
1798
                    threads.push(
×
UNCOV
1799
                        new Thread(thread.threadId, threadName)
×
1800
                    );
1801
                }
UNCOV
1802

×
1803
                if (threads.length === 0) {
1804
                    threads = [{
×
1805
                        id: 1001,
1806
                        name: 'unable to retrieve threads: not stopped',
UNCOV
1807
                        isFake: true
×
1808
                    }];
UNCOV
1809
                }
×
UNCOV
1810

×
UNCOV
1811
            } else {
×
UNCOV
1812
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
×
1813
            }
UNCOV
1814

×
UNCOV
1815
        }
×
1816

UNCOV
1817
        response.body = {
×
UNCOV
1818
            threads: threads
×
UNCOV
1819
        };
×
1820

1821
        this.sendResponse(response);
UNCOV
1822
    }
×
1823

UNCOV
1824
    /**
×
UNCOV
1825
     * Get the thread name to display in the UI based on the thread info we have.
×
UNCOV
1826
     * This is what displays in the `call stack` region in vscode
×
UNCOV
1827
     * @param thread
×
1828
     * @returns
UNCOV
1829
     */
×
UNCOV
1830
    private getThreadName(thread: AdapterThread) {
×
1831
        let threadName = '';
UNCOV
1832
        if (thread.type || thread.name || thread.osThreadId) {
×
UNCOV
1833
            //build the name from only the parts that are present, so missing values don't leak into the name
×
UNCOV
1834
            const parts: string[] = [];
×
1835
            if (thread.type) {
1836
                parts.push(`[${thread.type}]`);
UNCOV
1837
            }
×
UNCOV
1838
            if (thread.name) {
×
UNCOV
1839
                parts.push(thread.name);
×
1840
            }
1841
            if (thread.osThreadId) {
1842
                parts.push(thread.osThreadId);
1843
            }
4✔
1844
            threadName = parts.join(' ');
4✔
1845
        }
4✔
1846
        //remove any extraneous whitespace to deal with missing values
4✔
1847
        threadName = threadName.replace(/\s+/g, ' ').trim();
4✔
1848

1849
        if (threadName === '') {
4✔
1850
            threadName = `Thread ${thread.threadId}`;
4✔
1851
        }
1852

4✔
1853
        if (thread.isDetached) {
4!
UNCOV
1854
            threadName += ' [detached]';
×
UNCOV
1855
        }
×
UNCOV
1856

×
UNCOV
1857
        //remove any extraneous whitespace to deal with missing values
×
1858
        threadName = threadName.replace(/\s+/g, ' ').trim();
1859

1860
        return threadName;
4✔
1861
    }
4!
UNCOV
1862

×
UNCOV
1863
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
×
UNCOV
1864
        try {
×
1865
            this.logger.log('stackTraceRequest');
1866
            let frames: DebugProtocol.StackFrame[] = [];
4✔
1867

1868
            //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
4✔
1869
            if (this.compileError) {
2✔
1870
                frames.push(new StackFrame(
1871
                    0,
1872
                    'Compile Error',
4!
1873
                    new Source(path.basename(this.compileError.path), this.compileError.path),
UNCOV
1874
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
×
1875
                    this.toClientLine(this.compileError.range.start.line),
UNCOV
1876
                    this.toClientColumn(this.compileError.range.start.character)
×
UNCOV
1877
                ));
×
UNCOV
1878
            } else if (args.threadId === 1001) {
×
1879
                frames.push(new StackFrame(
×
UNCOV
1880
                    0,
×
1881
                    'ERROR: threads would not stop',
UNCOV
1882
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
×
1883
                    this.toClientLine(0),
1884
                    this.toClientColumn(0)
UNCOV
1885
                ));
×
1886
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1887
                    this.logger.error('Error showing popup message', { error });
×
UNCOV
1888
                });
×
UNCOV
1889
            } else {
×
UNCOV
1890
                //ensure the rokuAdapter is loaded
×
UNCOV
1891
                await this.getRokuAdapter();
×
1892

1893
                if (this.rokuAdapter.isAtDebuggerPrompt) {
UNCOV
1894
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
×
UNCOV
1895
                    if (stackTrace.length === 0) {
×
UNCOV
1896
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
×
UNCOV
1897
                        // the thread without letting the user navigate to a source location
×
UNCOV
1898
                        const frame = new StackFrame(0, '[unavailable]');
×
1899
                        frame.presentationHint = 'label';
UNCOV
1900
                        frames.push(frame);
×
UNCOV
1901
                    } else {
×
1902
                        for (let debugFrame of stackTrace) {
UNCOV
1903
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
×
UNCOV
1904

×
1905
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1906
                            //load the contents of the file and get the correct casing for the function identifier
UNCOV
1907
                            try {
×
1908
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
UNCOV
1909
                                if (functionName) {
×
1910

1911
                                    //search for original function name if this is an anonymous function.
UNCOV
1912
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
×
1913
                                    if (functionName.startsWith('$')) {
1914
                                        functionName = this.fileManager.getFunctionNameAtPosition(
1915
                                            sourceLocation.filePath,
UNCOV
1916
                                            sourceLocation.lineNumber - 1,
×
UNCOV
1917
                                            functionName
×
1918
                                        );
1919
                                    }
UNCOV
1920
                                    debugFrame.functionIdentifier = functionName;
×
UNCOV
1921
                                }
×
UNCOV
1922
                            } catch (error) {
×
1923
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
1924
                            }
UNCOV
1925
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
×
1926

1927
                            const frame: DebugProtocol.StackFrame = new StackFrame(
1928
                                debugFrame.frameId,
1929
                                `${debugFrame.functionIdentifier}`,
4✔
1930
                                new Source(path.basename(filePath), filePath),
1931
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1932
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
1933
                                this.toClientColumn(0)
4✔
1934
                            );
1935
                            if (!sourceLocation) {
1936
                                frame.presentationHint = 'subtle';
1937
                            }
1938
                            frames.push(frame);
1939
                        }
UNCOV
1940
                    }
×
UNCOV
1941
                } else {
×
1942
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1943
                }
1944
            }
1945
            response.body = {
4✔
1946
                stackFrames: frames,
1947
                totalFrames: frames.length
4✔
1948
            };
4!
UNCOV
1949
            this.sendResponse(response);
×
1950
        } catch (error) {
1951
            this.logger.error('Error getting stacktrace', { error, args });
1952
        }
1953
    }
1954

4!
UNCOV
1955
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
×
1956
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1957
        logger.info('begin', { args });
4!
1958
        try {
1959
            const scopes = new Array<DebugProtocol.Scope>();
4!
1960

UNCOV
1961
            // create the locals scope
×
UNCOV
1962
            let v = this.getOrCreateLocalsScope(args.frameId);
×
1963

1964
            let localScope: DebugProtocol.Scope = {
UNCOV
1965
                name: 'Local',
×
1966
                variablesReference: v.variablesReference,
1967
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1968
                expensive: this.launchConfiguration.deferScopeLoading,
4!
1969
                presentationHint: 'locals'
1970
            };
4✔
1971

1972
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
6✔
1973
            if (frame) {
4✔
1974
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
2✔
1975

1976
                if (scopeRange) {
4!
1977
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
1978
                    localScope.column = this.toClientColumn(scopeRange.start.column);
1979
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
4!
1980
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1981
                }
1982
            }
1983

4✔
1984
            scopes.push(localScope);
1985

1986
            // create the registry scope
1987
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
4✔
1988
            scopes.push(<DebugProtocol.Scope>{
1989
                name: 'Registry',
1990
                variablesReference: registryRefId,
1991
                expensive: true
1992
            });
1993

1994
            this.variables[registryRefId] = {
1995
                variablesReference: registryRefId,
4✔
1996
                name: 'Registry',
1997
                value: '',
3✔
1998
                type: '$$Registry',
1999
                isScope: true,
2000
                childVariables: []
1✔
2001
            };
1!
2002

1!
2003
            response.body = {
1✔
2004
                scopes: scopes
1✔
2005
            };
2006
            logger.debug('send response', { response });
×
2007
            this.sendResponse(response);
2008
            logger.info('end');
×
UNCOV
2009
        } catch (error) {
×
2010
            logger.error('Error getting scopes', { error, args });
UNCOV
2011
        }
×
UNCOV
2012
    }
×
UNCOV
2013

×
UNCOV
2014
    /**
×
UNCOV
2015
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
×
2016
     * The child variables are filled in lazily by `populateScopeVariables`.
UNCOV
2017
     */
×
2018
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
2019
        const refId = this.getEvaluateRefId('$$locals', frameId);
2020
        if (!this.variables[refId]) {
2021
            this.variables[refId] = {
2022
                variablesReference: refId,
2023
                name: 'Locals',
2024
                value: '',
2025
                type: '$$Locals',
1✔
2026
                frameId: frameId,
1✔
2027
                isScope: true,
1✔
2028
                childVariables: []
UNCOV
2029
            };
×
2030
        }
2031
        return this.variables[refId];
UNCOV
2032
    }
×
2033

2034
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
2035
        //if we have a compile error, we should shut down
2036
        if (this.compileError) {
×
2037
            this.sendResponse(response);
×
2038
            await this.shutdown();
2039
            return;
2040
        }
2041

2042
        this.logger.log('continueRequest');
2043
        await this.setTransientsToInvalid(); // call before clearState
×
2044
        this.clearState();
×
UNCOV
2045

×
2046
        // The debug session ends after the next line. Do not put new work after this line.
2047
        await this.rokuAdapter.continue();
2048
        this.sendResponse(response);
1✔
2049
    }
2050

1!
UNCOV
2051
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
×
2052
        this.logger.log('pauseRequest');
2053

2054
        //if we have a compile error, we should shut down
2055
        if (this.compileError) {
2056
            this.sendResponse(response);
2057
            await this.shutdown();
×
2058
            return;
×
UNCOV
2059
        }
×
2060

2061
        await this.rokuAdapter.pause();
2062
        this.sendResponse(response);
2063
    }
6✔
2064

5✔
2065
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
2066
        this.logger.log('reverseContinueRequest');
6✔
2067
        this.sendResponse(response);
6✔
2068
    }
6✔
2069

2070
    /**
2071
     * Clicked the "Step Over" button
2072
     * @param response
2073
     * @param args
15✔
2074
     */
15✔
2075
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
15!
2076
        this.logger.log('[nextRequest] begin');
×
UNCOV
2077

×
UNCOV
2078
        //if we have a compile error, we should shut down
×
2079
        if (this.compileError) {
×
2080
            this.sendResponse(response);
×
2081
            await this.shutdown();
×
2082
            return;
×
2083
        }
2084

15✔
2085
        await this.setTransientsToInvalid(); // call before clearState
15✔
2086
        this.clearState();
15✔
2087

2088
        // The debug session ends after the next line. Do not put new work after this line.
2089
        try {
15✔
2090
            await this.rokuAdapter.stepOver(args.threadId);
4✔
2091
            this.logger.info('[nextRequest] end');
2092
        } catch (error) {
15✔
2093
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
1✔
2094
        }
1!
2095
        this.sendResponse(response);
1✔
2096
    }
1✔
2097

2098
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
2099
        this.logger.log('[stepInRequest]');
2100

2101
        //if we have a compile error, we should shut down
2102
        if (this.compileError) {
×
2103
            this.sendResponse(response);
2104
            await this.shutdown();
2105
            return;
2106
        }
14!
2107

2108
        await this.setTransientsToInvalid(); // call before clearState
2109
        this.clearState();
14✔
2110
        // The debug session ends after the next line. Do not put new work after this line.
2111
        await this.rokuAdapter.stepInto(args.threadId);
14✔
2112
        this.sendResponse(response);
11✔
2113
        this.logger.info('[stepInRequest] end');
2114
    }
2115

11✔
2116
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
1✔
2117
        this.logger.log('[stepOutRequest] begin');
2118

2119
        //if we have a compile error, we should shut down
10✔
2120
        if (this.compileError) {
10!
2121
            this.sendResponse(response);
×
2122
            await this.shutdown();
2123
            return;
10✔
2124
        }
2125

2126
        await this.setTransientsToInvalid(); // call before clearState
10✔
2127
        this.clearState();
10✔
2128

2129
        // The debug session ends after the next line. Do not put new work after this line.
11✔
2130
        await this.rokuAdapter.stepOut(args.threadId);
2131
        this.sendResponse(response);
2132
        this.logger.info('[stepOutRequest] end');
2133
    }
21✔
2134

21✔
2135
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
2136
        this.logger.log('[stepBackRequest] begin');
2137
        this.sendResponse(response);
2138
        this.logger.info('[stepBackRequest] end');
2139
    }
3✔
2140

3✔
2141
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
3!
2142
        const logger = this.logger.createLogger('[variablesRequest]');
2143
        let sendInvalidatedEvent = false;
2144
        let frameId: number = null;
3✔
2145
        try {
3✔
2146
            logger.log('begin', { args });
2147

2148
            //ensure the rokuAdapter is loaded
3✔
2149
            await this.getRokuAdapter();
1✔
2150

1!
2151
            let updatedVariables: AugmentedVariable[] = [];
2152
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
2153
            await this.evaluateRequestPromise;
1!
2154
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
2155
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
2156
                response.success = false;
3✔
2157
                response.message = 'Debug session is not paused';
2✔
2158
                return this.sendResponse(response);
2159
            }
2160

2161
            //find the variable with this reference
2162
            let v = this.variables[args.variablesReference];
2163
            if (!v) {
1✔
2164
                response.success = false;
1!
2165
                response.message = `Variable reference has expired`;
2166
                return this.sendResponse(response);
2167
            }
2168
            logger.log('variable', v);
2169

2170
            // Populate scope level values if needed
2171
            if (v.isScope) {
UNCOV
2172
                await this.populateScopeVariables(v, args);
×
UNCOV
2173
            }
×
UNCOV
2174

×
2175
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
2176
            if (v.childVariables.length === 0 || v.isResolved) {
15✔
2177
                let tempVar: AugmentedVariable;
15✔
2178
                if (!v.isResolved) {
2179
                    // Evaluate the variable
2180
                    try {
15✔
2181
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
2182
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
2183
                        tempVar = await this.getVariableFromResult(result, v.frameId);
19✔
2184
                        tempVar.frameId = v.frameId;
19✔
2185
                        // Determine if the variable has changed
6✔
2186
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
6✔
2187
                    } catch (error) {
6✔
2188
                        logger.error('Error getting variables', error);
6✔
2189
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
5✔
2190
                        tempVar.type = '';
2191
                        tempVar.childVariables = [];
6✔
2192
                        sendInvalidatedEvent = true;
6✔
2193
                        response.success = false;
6✔
2194
                        response.message = error.message;
6✔
2195
                    }
6!
UNCOV
2196

×
2197
                    // Merge the resulting updates together
2198
                    v.childVariables = tempVar.childVariables;
6✔
2199
                    v.value = tempVar.value;
2200
                    v.type = tempVar.type;
19✔
2201
                    v.indexedVariables = tempVar.indexedVariables;
2202
                    v.namedVariables = tempVar.namedVariables;
UNCOV
2203
                }
×
2204
                frameId = v.frameId;
2205

2206
                if (v?.presentationHint?.lazy || v.isResolved) {
UNCOV
2207
                    // If this was a lazy variable we need to respond with the updated variable and not the children
×
2208
                    if (v.isResolved && v.childVariables.length > 0) {
×
2209
                        updatedVariables = v.childVariables;
×
UNCOV
2210
                    } else {
×
2211
                        updatedVariables = [v];
×
UNCOV
2212
                    }
×
2213
                    v.isResolved = true;
×
UNCOV
2214
                } else {
×
2215
                    updatedVariables = v.childVariables;
×
UNCOV
2216
                }
×
UNCOV
2217

×
2218
                // If the variable has no children, set the reference to 0
UNCOV
2219
                // so it does not look expandable in the Ui
×
2220
                if (v.childVariables.length === 0) {
×
2221
                    v.variablesReference = 0;
×
UNCOV
2222
                }
×
UNCOV
2223

×
2224
                // If the variable was resolve in the past we may not have fetched a new temp var
2225
                tempVar ??= v;
×
2226
                if (v?.presentationHint) {
2227
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2228
                } else {
2229
                    v.presentationHint = tempVar.presentationHint;
×
UNCOV
2230
                }
×
UNCOV
2231

×
UNCOV
2232
            } else {
×
UNCOV
2233
                updatedVariables = v.childVariables;
×
2234
            }
UNCOV
2235

×
UNCOV
2236
            // Only send the updated variables if we are not going to trigger an invalidated event.
×
UNCOV
2237
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
×
UNCOV
2238
            response.body = {
×
UNCOV
2239
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
×
UNCOV
2240
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
×
2241
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2242
            };
UNCOV
2243
        } catch (error) {
×
2244
            logger.error('Error during variablesRequest', error, { args });
2245
            response.success = false;
2246
            response.message = error?.message ?? 'Error during variablesRequest';
2247
        } finally {
20✔
2248
            logger.info('end', { response });
2249
        }
2250
        this.sendResponse(response);
20✔
2251
        if (sendInvalidatedEvent) {
20✔
2252
            this.debounceSendInvalidatedEvent(null, frameId);
20✔
2253
        }
20!
2254
    }
UNCOV
2255

×
2256
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
2257
        this.sendInvalidatedEvent(threadId, frameId);
UNCOV
2258
    }, 50);
×
2259

2260

20✔
2261
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
20✔
2262
        if (!updatedVariables || !v) {
2263
            return [];
2264
        }
2265

20✔
2266
        let start = args.start ?? 0;
2267

2268
        //if the variable is an array, send only the requested range
2269
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
20✔
2270
            //only send the variable range requested by the debugger
2271
            if (!args.count) {
2272
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
2273
            } else {
20✔
2274
                updatedVariables = updatedVariables.slice(start, start + args.count);
20!
2275
            }
20✔
2276
        }
2277

20!
2278
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
2279
            // We currently do not support named variable paging so we always send all named variables
20✔
2280
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2281
        }
2282

2283
        let filteredUpdatedVariables = this.launchConfiguration.showHiddenVariables !== true ? updatedVariables.filter(
20✔
2284
            (child: AugmentedVariable) => !child.name.startsWith(this.tempVarPrefix)) : updatedVariables;
2285

20✔
2286
        if (this.launchConfiguration.showHiddenVariables !== true) {
2287
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2288
                //A transient variable that we show when there is a value
2289
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
19✔
2290
                    return true;
2291
                } else if (util.isTransientVariable(child.name)) {
2292
                    return false;
2293
                } else {
19✔
2294
                    return true;
2295
                }
2296
            });
21!
2297
        }
19✔
2298

2299
        return filteredUpdatedVariables;
21✔
2300
    }
21✔
2301

2302
    /**
17!
2303
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
17✔
2304
     * @param v scope variable to populate
2305
     * @param args
17!
2306
     */
2307
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
UNCOV
2308
        if (v.childVariables.length > 0) {
×
UNCOV
2309
            // Already populated
×
2310
            return;
2311
        }
17✔
2312

2313
        let tempVar: AugmentedVariable;
2314
        try {
21✔
2315
            if (v.type === '$$Locals') {
2316
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
2317
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
2318
                    tempVar = await this.getVariableFromResult(result, v.frameId);
2319
                } else if (this.rokuAdapter.isTelnetAdapter()) {
2320
                    // NOTE: Legacy telnet support
21✔
2321
                    let variables: AugmentedVariable[] = [];
2322
                    const varNames = await this.rokuAdapter.getScopeVariables();
2323

2324
                    // Fetch each variable individually
4✔
2325
                    for (const varName of varNames) {
2326
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
17✔
2327
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
2328
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
2329
                        variables.push(tempLocalsVar);
2330
                    }
3✔
2331
                    tempVar = {
3✔
2332
                        ...v,
3✔
2333
                        childVariables: variables,
2334
                        namedVariables: variables.length,
21✔
2335
                        indexedVariables: 0
2336
                    };
2337
                }
19✔
2338

15✔
2339
                // Merge the resulting updates together onto the original variable
2340
                v.childVariables = tempVar.childVariables;
15✔
2341
                v.namedVariables = tempVar.namedVariables;
2342
                v.indexedVariables = tempVar.indexedVariables;
90!
2343
            } else if (v.type === '$$Registry') {
2344
                // This is a special scope variable used to load registry data via an ECP call
29!
2345
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
2346
                await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
90!
2347
            }
2348
        } catch (error) {
2349
            logger.error(`Error getting variables for scope ${v.type}`, error);
15✔
2350
            tempVar = {
185✔
2351
                name: '',
2352
                value: `❌ Error: ${error.message}`,
2353
                variablesReference: 0,
555!
2354
                childVariables: []
2355
            };
2356
            v.childVariables = [tempVar];
2357
            v.namedVariables = 1;
2358
            v.indexedVariables = 0;
2359
        }
19✔
2360

4✔
2361
        // Mark the scope as resolved so we don't re-fetch the variables
308✔
2362
        v.isResolved = true;
2363

2364
        // If the scope has no children, add a single child to indicate there are no values
1,848!
2365
        if (v.childVariables.length === 0) {
2366
            tempVar = {
2367
                name: '',
2368
                value: `No values for scope '${v.name}'`,
4✔
2369
                variablesReference: 0,
4✔
2370
                childVariables: []
4✔
2371
            };
4✔
2372
            v.childVariables = [tempVar];
1!
2373
            v.namedVariables = 1;
1✔
2374
            v.indexedVariables = 0;
2375
        }
2376
    }
2377

2378
    private evaluateRequestPromise = Promise.resolve();
2379
    private evaluateVarIndexByFrameId = new Map<number, number>();
2380

2381
    private getNextVarIndex(frameId: number): number {
UNCOV
2382
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
×
2383
            this.evaluateVarIndexByFrameId.set(frameId, 0);
2384
        }
2385
        let value = this.evaluateVarIndexByFrameId.get(frameId);
2386
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
2387
        return value;
2388
    }
2389

20✔
2390
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
499✔
2391
        //ensure the rokuAdapter is loaded
496✔
2392
        await this.getRokuAdapter();
496✔
2393

2394
        let deferred = defer<void>();
2395
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
20✔
2396
            this.clearState();
2397
            this.rokuAdapter.clearCache();
2398
            const expression = args.expression.replace(/^\s*>\s*/, '');
2399
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
2400
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
2401
            this.sendResponse(response);
2402
            return deferred.promise;
×
2403
        }
2404

20✔
2405
        try {
2406
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
2407
                return deferred.promise;
2408
            });
2409

2410
            //fix vscode hover bug that excludes closing quotemark sometimes.
2411
            if (args.context === 'hover') {
69✔
2412
                args.expression = util.ensureClosingQuote(args.expression);
69✔
2413
            }
69✔
2414

69✔
2415
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
69!
2416
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
69✔
2417
                if (args.context === 'repl') {
69✔
2418
                    this.sendEvent(new OutputEvent(message, 'stderr'));
2419
                    response.body = {
2420
                        result: 'invalid',
69✔
2421
                        variablesReference: 0
2✔
2422
                    };
2423
                } else {
2424
                    throw new Error(message);
2425
                }
2426

67✔
2427
                //is at debugger prompt
67✔
2428
            } else if (args.expression.trim()) {
2429
                // We trim and check that the expression is not an empty string so that we do not send empty expressions to the Roku
2430
                // This happens mostly when hovering over leading whitespace in the editor
2431

67✔
2432
                let { evalArgs, variablePath } = await this.evaluateExpressionToTempVar(args, util.getVariablePath(args.expression));
67✔
2433

2434
                //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`
12✔
2435
                if (variablePath) {
12✔
UNCOV
2436
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
×
2437
                    let v: AugmentedVariable;
2438
                    //if we already looked this item up, return it
12✔
2439
                    if (this.variables[refId]) {
12✔
2440
                        v = this.variables[refId];
2441
                    } else {
8✔
2442
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
8✔
2443
                        if (!result) {
2444
                            throw new Error('Error: unable to evaluate expression');
8✔
2445
                        }
2446

2447
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
2448
                        //TODO - testing something, remove later
2449
                        // eslint-disable-next-line camelcase
67✔
2450
                        v.request_seq = response.request_seq;
67✔
2451
                        v.frameId = evalArgs.frameId;
67✔
2452
                    }
438✔
2453
                    response.body = {
438✔
2454
                        result: v.value,
10✔
2455
                        type: v.type,
2456
                        variablesReference: v.variablesReference,
428✔
2457
                        namedVariables: v.namedVariables || 0,
12✔
2458
                        indexedVariables: v.indexedVariables || 0
2459
                    };
2✔
2460

2461
                    //run an `evaluate` call
10✔
2462
                } else {
2463
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
416✔
2464

23✔
2465
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
2466
                    if (args.context === 'repl') {
413✔
2467
                        // Clear variable cache since this action could have side-effects
2468
                        // Only do this for REPL requests as hovers and watches should not clear the cache
67✔
2469
                        this.clearState();
2470
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
2471
                    }
2472

67✔
2473
                    // If the adapter captured output (probably only telnet), log the results
2✔
2474
                    if (typeof commandResults.message === 'string') {
2475
                        this.logger.debug('evaluateRequest', { commandResults });
65✔
2476
                        if (args.context === 'repl') {
25✔
2477
                            // If the command was a repl command, send the output to the debug console for the developer as well
2478
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2479
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
2480
                        }
65✔
2481
                    }
2482

10✔
2483
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
2484
                        response.body = {
55✔
2485
                            result: 'invalid',
2486
                            variablesReference: 0
25✔
2487
                        };
2488
                    } else {
2489
                        response.body = {
30✔
2490
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
2491
                            variablesReference: 0
2492
                        };
65✔
2493
                    }
2✔
2494
                }
2495
            }
2496
        } catch (error) {
2497
            this.logger.error('Error during variables request', error);
63✔
2498
            response.success = false;
2499
            response.message = error?.message ?? error;
63✔
2500
        }
17✔
2501
        try {
2502
            this.sendResponse(response);
63✔
2503
        } catch { }
2504
        deferred.resolve();
63✔
2505
    }
8✔
2506

2507
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
63✔
2508
        let returnVal = { evalArgs: args, variablePath };
2509
        if (!variablePath && util.isAssignableExpression(args.expression)) {
2510
            let varIndex = this.getNextVarIndex(args.frameId);
2511
            let arrayVarName = this.tempVarPrefix + 'eval';
2512
            let command = '';
2513
            if (varIndex === 0) {
2514
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
2515
            }
2516
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
2517
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
2518
            command += statement;
2519
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
2520
            if (commandResults.type === 'error') {
2521
                throw new Error(commandResults.message);
20✔
2522
            }
20✔
2523
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
20✔
2524
        }
20!
2525
        return returnVal;
20✔
2526
    }
20✔
2527

20✔
2528
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
7✔
2529
        let results = {
2530
            evaluations: [],
20✔
2531
            bulkVarName: ''
2532
        };
2533
        let storedVariables = [];
2534
        let command = '';
2535
        for (let i = 0; i < argsArray.length; i++) {
2536
            let args = argsArray[i];
2537
            let variablePath = variablePathArray[i];
2538
            let returnVal = { evalArgs: args, variablePath };
2539
            if (!variablePath && util.isAssignableExpression(args.expression)) {
2540
                let varIndex = this.getNextVarIndex(frameId);
67✔
2541
                let arrayVarName = this.tempVarPrefix + 'eval';
67✔
2542
                if (varIndex === 0) {
604✔
2543
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
604✔
2544
                }
12✔
2545
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
2546
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
592✔
2547
                command += statement;
34✔
2548

22✔
2549
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
2550
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
12✔
2551
            }
2552

2553
            results.evaluations[i] = returnVal;
45✔
2554
        }
2555

2556
        if (command) {
2557

2558
            // create a bulk container for the command results
2559
            let varIndex = this.getNextVarIndex(frameId);
2560
            let arrayVarName = this.tempVarPrefix + 'eval';
2561
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
2562
            for (let storedVariable of storedVariables) {
2563
                bulkContainerStatement += `${storedVariable},\n`;
20✔
2564
            }
20✔
2565
            bulkContainerStatement += `]`;
4✔
2566

4!
2567
            command += bulkContainerStatement;
4✔
2568

4✔
2569
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
2570

2571
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2572
            if (commandResults.type === 'error') {
2573
                throw new Error(commandResults.message);
2574
            }
2575
        }
20✔
2576

20✔
2577
        return results;
14✔
2578
    }
2579

2580
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2581
        this.logger.log('completionsRequest', args, request);
6✔
2582
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
6✔
2583
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
6✔
2584

1✔
2585
        try {
2586
            let supplyLocalScopeCompletions = false;
2587

5✔
2588
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
5✔
2589

5✔
2590
            if (!closestCompletionDetails) {
4✔
2591
                // If the cursor is not at the end of the line, then we should not supply completions at this time
2592
                response.body = {
2593
                    targets: []
2594
                };
1✔
2595
                return this.sendResponse(response);
1✔
2596
            }
2597
            let completions = new Map<string, DebugProtocol.CompletionItem>();
5✔
2598

5✔
2599
            let parentVariablePath = closestCompletionDetails.parentVariablePath;
2600
            // When set, the user is typing a string key (ex: `m["fo`) and completions should insert the
2601
            // key wrapped to close the access (ex: `firstName"]`) rather than appending a bare label. The
2602
            // value is the closing text to append (empty when a closing bracket is already present).
2603
            const stringKeyClosing = closestCompletionDetails.stringKeyClosing;
2604

2605
            // The span of input each completion replaces. The client requests completions once (at the first
2606
            // character) and then filters the list as the user keeps typing, so without an explicit range that
2607
            // incremental filtering is anchored incorrectly.
14✔
2608
            const replaceRange = this.getCompletionReplaceRange(args);
27✔
2609
            // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
14✔
2610
            // member access). A key that can't be dot-accessed (ex: `my key`) is rewritten as bracket access,
2611
            // which has to consume that `.` so `m.` becomes `m["my key"]` rather than `m.["my key"]`.
2612
            const lines = args.text.split('\n');
2613
            const targetLine = lines[this.toDebuggerLine(args.line, 0)] ?? '';
13✔
2614
            const precededByDot = targetLine[replaceRange.start - 1] === '.';
2✔
2615

2616
            // Get the completions if the variable path was valid
11✔
2617
            if (parentVariablePath) {
3✔
2618

2619
                // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
8✔
2620
                if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
5✔
2621
                    supplyLocalScopeCompletions = true;
2622
                }
3✔
2623

2624
                // Look up the parent variable (in-memory first, then the device), scoped to the current frame.
2625
                let parentVariable = await this.resolveCompletionParentVariable(parentVariablePath, args.frameId);
2626

2627
                // provide completions for the parent variable if one was found
2628
                if (parentVariable) {
2629
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2630
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2631
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2632
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
46!
2633
                        parentVariable.type === VariableType.List ||
46✔
2634
                        parentVariable.type === 'roXMLList' ||
2✔
2635
                        parentVariable.type === 'roByteArray';
2636

46✔
2637
                    const possibleFieldsAndMethods = isIntegerIndexed
2638
                        ? []
2639
                        // Filter out virtual variables and the empty-named placeholder used for empty scopes
2640
                        : parentVariable.childVariables.filter((child) => child.name && child.presentationHint?.kind !== 'virtual');
2641

2642
                    for (let v of possibleFieldsAndMethods) {
2643
                        // Default completion type should be variable
2644
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
2645
                        if (!supplyLocalScopeCompletions) {
20✔
2646
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
20✔
2647
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
4✔
2648
                                completionType = 'field';
2649
                            }
16✔
2650

2651
                            switch (v.type) {
2652
                                case VariableType.Function:
2653
                                case VariableType.Subroutine:
22✔
2654
                                    completionType = 'method';
22✔
2655
                                    break;
26✔
2656
                                default:
2657
                                    break;
26!
2658
                            }
20✔
2659
                        }
2660

2661
                        const completionItem: DebugProtocol.CompletionItem = {
26✔
2662
                            label: v.name,
7✔
2663
                            type: completionType,
2664
                            //rank a variable's own members/locals above everything else
2665
                            sortText: `${CompletionSortTier.Member}${v.name}`
19✔
2666
                        };
2667
                        if (stringKeyClosing !== undefined) {
15✔
2668
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2669
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2670
                            // (ex: a key of `a"b` is inserted as `a""b`).
15!
2671
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
2672
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
UNCOV
2673
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
×
2674
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2675
                            // the key is escaped as `""` so the inserted string literal stays valid.
11✔
2676
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
UNCOV
2677
                            completionItem.start = replaceRange.start - 1;
×
2678
                            completionItem.length = replaceRange.length + 1;
2679
                        }
1✔
2680
                        completions.set(`${completionType}-${v.name}`, completionItem);
UNCOV
2681
                    }
×
2682

UNCOV
2683
                    // Interface methods aren't valid string keys, so skip them when completing a string key
×
2684
                    if (stringKeyClosing === undefined) {
UNCOV
2685
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
×
2686
                        //assemble a list of all methods on the parent component
UNCOV
2687
                        const methods = [
×
2688
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
UNCOV
2689
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
×
2690
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
UNCOV
2691
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
×
2692
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2693
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
3✔
2694
                        ].flat();
2695

2696
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2697
                        for (const method of methods) {
2698
                            completions.set(`method-${method.name}`, {
2699
                                label: method.name,
2700
                                type: 'method',
2701
                                detail: method.description ?? '',
2702
                                sortText: `${CompletionSortTier.Method}${method.name}`
2703
                            });
2704
                        }
2705
                    }
2706

2707
                    // Add the global functions to the completions results
2708
                    if (supplyLocalScopeCompletions) {
2!
2709
                        for (let globalCallable of globalCallables) {
2✔
2710
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
2✔
2711
                                label: globalCallable.name,
2712
                                type: 'function',
2713
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
2✔
2714
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2715
                            });
2716
                        }
2✔
2717

2✔
2718
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
2719

2720
                        try {
1!
UNCOV
2721
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
×
2722
                            for (let scopeFunction of scopeFunctions) {
2723
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
2724
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2725
                                        label: scopeFunction.name,
2726
                                        type: scopeFunction.completionItemKind,
2727
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
UNCOV
2728
                                    });
×
UNCOV
2729
                                }
×
UNCOV
2730
                            }
×
UNCOV
2731
                        } catch (e) {
×
2732
                            this.logger.warn('Could not build list of scope functions for file', e);
UNCOV
2733
                        }
×
UNCOV
2734
                    }
×
UNCOV
2735
                }
×
UNCOV
2736
            }
×
UNCOV
2737

×
2738
            // Apply the default replacement span to every completion that didn't already set its own (bracket
UNCOV
2739
            // rewrites above use an extended range that also consumes the preceding `.`).
×
2740
            for (const target of completions.values()) {
2741
                if (target.start === undefined) {
UNCOV
2742
                    target.start = replaceRange.start;
×
UNCOV
2743
                    target.length = replaceRange.length;
×
UNCOV
2744
                }
×
UNCOV
2745
            }
×
2746

UNCOV
2747
            response.body = {
×
UNCOV
2748
                targets: [...completions.values()]
×
2749
            };
2750
        } catch (error) {
2751
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2752
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
2753
            this.logger.error('Error during completionsRequest', error, { args });
UNCOV
2754
        }
×
UNCOV
2755
        this.sendResponse(response);
×
2756
    }
2757

2758
    /**
2759
     * Gets the closest completion details the incoming completion request.
UNCOV
2760
     */
×
2761
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2762
        const incomingText = args.text;
2763
        const lines = incomingText.split('\n');
2764
        let lineNumber = this.toDebuggerLine(args.line, 0);
2765
        let column = this.toDebuggerColumn(args.column);
2766

UNCOV
2767
        const targetLine = lines[lineNumber] ?? '';
×
UNCOV
2768

×
2769
        const cursorIndex = column - 1;
2770
        const variableChars = /[a-z0-9_\.]/i;
2771

UNCOV
2772
        // If the character immediately to the right of the cursor is a variable character, then we are
×
2773
        // in the middle of a token and should not supply completions yet.
UNCOV
2774
        if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
×
2775
            return undefined;
2776
        }
2777

2778
        // Determine where the expression we want to complete ends, and whether we are completing the
2779
        // members of that expression. A trailing `.` or being inside an unclosed string-key bracket
2780
        // (ex: `m["fo`) are both treated as member access on the parent expression.
UNCOV
2781
        let endColumn = column;
×
UNCOV
2782
        let isMemberAccess = false;
×
UNCOV
2783
        //when set (including ''), the user is completing a string key; the value is the text to append to
×
UNCOV
2784
        //close the access (ex: `"]`), empty when a closing bracket is already present
×
2785
        let stringKeyClosing: string;
2786

UNCOV
2787
        const openBracket = this.findUnclosedOpener(targetLine, column);
×
UNCOV
2788
        if (openBracket?.char === '[') {
×
2789
            // find the opening quote (skipping any whitespace after the `[`)
2790
            let quoteIndex = openBracket.index + 1;
2791
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
2792
                quoteIndex++;
×
UNCOV
2793
            }
×
2794
            const quote = targetLine[quoteIndex];
2795
            if (quote === '"' || quote === `'`) {
2796
                // The user is typing a string key, so complete the keys of the expression before the `[`
UNCOV
2797
                endColumn = openBracket.index;
×
2798
                isMemberAccess = true;
UNCOV
2799
                // close the string and bracket only when there is nothing meaningful after the cursor
×
UNCOV
2800
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
×
UNCOV
2801
            }
×
UNCOV
2802
        }
×
2803

2804
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
UNCOV
2805
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
×
UNCOV
2806
        let startIndex = endColumn - 1;
×
2807
        let bracketDepth = 0;
2808
        while (startIndex >= 0) {
UNCOV
2809
            const char = targetLine[startIndex];
×
UNCOV
2810
            if (char === ']') {
×
UNCOV
2811
                bracketDepth++;
×
2812
            } else if (char === '[') {
2813
                if (bracketDepth === 0) {
2814
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2815
                    break;
1✔
2816
                }
1✔
2817
                bracketDepth--;
2818
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
1!
2819
                break;
1✔
2820
            }
2821
            startIndex--;
1!
UNCOV
2822
        }
×
UNCOV
2823

×
2824
        const variablePathString = targetLine.slice(startIndex + 1, endColumn);
2825

2826
        // Attempted dot access on something unexpected.
1✔
2827
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2828
        // which leaves `.name` as the variable path string.
6!
2829
        if (variablePathString.startsWith('.')) {
2830
            return undefined;
2831
        }
1✔
2832

1✔
2833
        if (variablePathString.endsWith('.')) {
2834
            isMemberAccess = true;
2835
        }
2836

2837
        // Get the variable path from the text
8✔
2838
        let variablePath: string[];
8✔
2839
        if (!variablePathString.trim()) {
2840
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2841
            variablePath = [''];
8✔
2842
        } else if (variablePathString.endsWith('.')) {
2843
            // supplied text ends with a period, so strip it off to create a valid variable path
9✔
2844
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
9✔
2845
        } else {
9✔
2846
            variablePath = util.getVariablePath(variablePathString);
2847
        }
2848

9✔
2849
        // the target string is not a valid variable path
2✔
2850
        if (!variablePath) {
2✔
2851
            return undefined;
2!
2852
        }
2853

2854
        // For member access we complete the members of the full expression. Otherwise we complete the
8✔
2855
        // siblings of the final (partial) segment, so drop it to get the parent.
4✔
2856
        let parentVariablePath = isMemberAccess ? variablePath : variablePath.slice(0, variablePath.length - 1);
4✔
2857

2858
        // An empty parent path means we are looking up the local scope variables and global functions
2859
        if (parentVariablePath.length === 0) {
4✔
2860
            parentVariablePath = [''];
1✔
UNCOV
2861
        }
×
2862

2863
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
1✔
2864
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
1✔
2865
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
2866
            result.stringKeyClosing = stringKeyClosing;
2867
        }
2868
        return result;
2869
    }
8!
2870

8✔
2871
    /**
8✔
2872
     * Compute the span of input text that a completion replaces: the run of identifier characters
8✔
2873
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2874
     * typing past the first character.
15✔
2875
     *
2876
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2877
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
25!
2878
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
25✔
2879
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
16✔
2880
     */
16!
UNCOV
2881
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
×
2882
        const lines = args.text.split('\n');
UNCOV
2883
        const lineNumber = this.toDebuggerLine(args.line, 0);
×
UNCOV
2884
        const cursorOffset = this.toDebuggerColumn(args.column);
×
UNCOV
2885
        const targetLine = lines[lineNumber] ?? '';
×
2886

2887
        const identifierChars = /[a-z0-9_]/i;
UNCOV
2888
        let wordStart = cursorOffset;
×
UNCOV
2889
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
×
2890
            wordStart--;
2891
        }
2892
        return {
2893
            start: wordStart,
2894
            length: cursorOffset - wordStart
2895
        };
2896
    }
2897

2898
    /**
2899
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2900
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
16✔
2901
     */
5!
2902
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
5✔
2903
        let depth = 0;
5✔
2904
        for (let i = column - 1; i >= 0; i--) {
5!
2905
            const char = line[i];
2906
            if (char === ')' || char === ']' || char === '}') {
UNCOV
2907
                depth++;
×
UNCOV
2908
            } else if (char === '(' || char === '[' || char === '{') {
×
2909
                if (depth === 0) {
2910
                    return { index: i, char: char };
2911
                }
5!
2912
                depth--;
UNCOV
2913
            }
×
2914
        }
2915
        return undefined;
5!
2916
    }
2917

5✔
2918
    /**
2919
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
5✔
2920
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2921
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2922
     */
2923
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
11!
UNCOV
2924
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
×
2925
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2926
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
11!
UNCOV
2927
        if (isLocalScope) {
×
2928
            const localsScope = this.getOrCreateLocalsScope(frameId);
2929
            if (!localsScope.isResolved) {
2930
                try {
11✔
2931
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
2932
                } catch (error) {
2933
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
2934
                }
11!
2935
            }
2936
        }
16✔
2937

2938
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
9!
2939
        if (inMemory && inMemory.childVariables.length > 0) {
9✔
2940
            return inMemory;
7✔
2941
        }
2942

2✔
2943
        // Rebuild a valid accessor expression for the device lookup. Joining with `.` is wrong for indexed
1✔
2944
        // segments (ex: `m.services[0]` would become the invalid `m.services.0` and the index gets dropped).
1!
2945
        const expression = this.buildVariableExpression(parentVariablePath);
1✔
2946

2947
        const cacheKey = `${frameId}:${expression}`;
1!
2948
        if (this.completionParentVariableCache.has(cacheKey)) {
2949
            return this.completionParentVariableCache.get(cacheKey);
2950
        }
1!
2951

1✔
2952
        let parentVariable: AugmentedVariable;
2953
        try {
1!
2954
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
1✔
2955
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
UNCOV
2956
            parentVariable = await this.getVariableFromResult(result, frameId);
×
UNCOV
2957
        } catch (error) {
×
2958
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2959
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
2960
            parentVariable = undefined;
UNCOV
2961
        }
×
2962

2963
        this.completionParentVariableCache.set(cacheKey, parentVariable);
2964
        return parentVariable;
25✔
2965
    }
25✔
2966

25✔
2967
    /**
25✔
2968
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
25!
2969
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
25!
UNCOV
2970
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
×
2971
     * use dot access. This keeps array indices and string keys correct through the device lookup.
2972
     */
25✔
2973
    private buildVariableExpression(segments: string[]): string {
7!
2974
        return segments.reduce((expression, segment, index) => {
7✔
2975
            if (index === 0) {
2976
                return segment;
2977
            }
7✔
2978
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2979
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
10!
2980
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
10✔
2981
                return `${expression}[${segment}]`;
2982
            }
7!
UNCOV
2983
            if (/^[0-9]+$/.test(segment)) {
×
UNCOV
2984
                return `${expression}[${segment}]`;
×
UNCOV
2985
            }
×
2986
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
UNCOV
2987
                return `${expression}.${segment}`;
×
UNCOV
2988
            }
×
2989
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
UNCOV
2990
        }, '');
×
UNCOV
2991
    }
×
UNCOV
2992

×
UNCOV
2993
    /**
×
UNCOV
2994
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
×
UNCOV
2995
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
×
UNCOV
2996
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
×
UNCOV
2997
     * the user typed (ex: `topRef` matching the cached `topref`).
×
UNCOV
2998
     */
×
2999
    private normalizeVariableName(name: string): string {
3000
        let value = name ?? '';
3001
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
3002
            value = value.slice(1, -1).replace(/""/g, '"');
UNCOV
3003
        }
×
3004
        return value.toLowerCase();
3005
    }
3006

3007
    /**
7✔
3008
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
10✔
3009
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
3010
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
3011
     */
3012
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
18✔
3013
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
3014
        if (path.length === 1 && path[0] === '') {
3015
            return localsContainer;
25!
UNCOV
3016
        }
×
UNCOV
3017
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
×
3018
    }
3019

UNCOV
3020
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
×
3021
        let current: AugmentedVariable = null;
3022
        for (const name of path) {
3023
            const normalizedName = this.normalizeVariableName(name);
3024
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
25✔
3025
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
3026
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
3027
            });
3028

3029
            // If no match is found, return null
3030
            if (!current) {
UNCOV
3031
                return null;
×
UNCOV
3032
            }
×
UNCOV
3033

×
UNCOV
3034
            // Move to the children for the next iteration
×
UNCOV
3035
            variables = current.childVariables;
×
UNCOV
3036
        }
×
UNCOV
3037
        return current;
×
3038
    }
3039

3040
    private debuggerVarTypeToRoType(type: string): string {
77✔
3041
        switch (type) {
77✔
3042
            case VariableType.Function:
45✔
3043
            case VariableType.Subroutine:
3044
                return 'roFunction';
77✔
3045
            case VariableType.AssociativeArray:
3046
                return 'roAssociativeArray';
3047
            case VariableType.List:
3048
                return 'roList';
12✔
3049
            case VariableType.Array:
12✔
3050
                return 'roArray';
3051
            case VariableType.Boolean:
3052
                return 'roBoolean';
3053
            case VariableType.Double:
3054
                return 'roDouble';
3055
            case VariableType.Float:
3056
                return 'roFloat';
3057
            case VariableType.Integer:
3058
                return 'roInteger';
3059
            case VariableType.LongInteger:
75✔
3060
                return 'roLongInteger';
44✔
3061
            case VariableType.String:
3062
                return 'roString';
31✔
3063
            default:
10✔
3064
                return type;
10✔
3065
        }
3066
    }
21✔
3067

18✔
3068
    /**
13✔
3069
     * Called when the host stops debugging
3070
     * @param response
3071
     * @param args
5✔
3072
     */
5✔
3073
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
5✔
3074
        //return to the home screen — best effort. The device may already be powered off or unreachable
5✔
3075
        //at disconnect time; without a guard pressHomeButton rejects (EHOSTDOWN / ECONNREFUSED / etc)
3076
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
5✔
3077
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
3078
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
3079
        //    https://github.com/rokucommunity/roku-debug/issues/332
3080
        if (!this.enableDebugProtocol) {
3081
            try {
3082
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
3083
            } catch (e) {
3084
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
3085
            }
3086
        }
3087
        this.sendResponse(response);
3!
UNCOV
3088
        await this.shutdown();
×
3089
    }
3090

3091
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
3092
        if (this.enableDebugProtocol) {
3093
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
3094
        } else {
3095
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
4!
3096
        }
4✔
3097
    }
4✔
3098

1✔
3099
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
3100
        this.logger.log('[restartRequest] begin');
3101
        if (this.rokuAdapter) {
3102
            if (!this.enableDebugProtocol) {
3103
                this.rokuAdapter.removeAllListeners();
3104
            }
3105
            await this.rokuAdapter.destroy();
3106
            await this.ensureAppIsInactive();
3107
            this.rokuAdapterDeferred = defer();
3108
            this.stagingDefered.tryResolve();
3109
            this.stagingDefered = defer();
3110
        }
3!
3111
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
3112
    }
3113

3114
    private exitAppTimeout = 5000;
3115
    private async ensureAppIsInactive() {
3116
        const startTime = Date.now();
3117

3118
        while (true) {
3119
            if (Date.now() - startTime > this.exitAppTimeout) {
3120
                return;
3!
3121
            }
3122

3123
            try {
3124
                let appStateResult = await rokuECP.getAppState({
3125
                    host: this.launchConfiguration.host,
3126
                    remotePort: this.launchConfiguration.remotePort,
3127
                    appId: 'dev',
3128
                    requestOptions: { timeout: 300 }
3129
                });
3130

109✔
3131
                const state = appStateResult.state;
2✔
3132

3133
                if (state === AppState.active || state === AppState.background) {
107✔
3134
                    // Suspends or terminates an app that is running:
3135
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
3136
                    // 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.
3137
                    // This means that we might need to send this command twice to terminate the app.
3138
                    await rokuECP.exitApp({
3139
                        host: this.launchConfiguration.host,
3140
                        remotePort: this.launchConfiguration.remotePort,
3141
                        appId: 'dev',
3142
                        requestOptions: { timeout: 300 }
3143
                    });
89!
3144
                } else if (state === AppState.inactive) {
89✔
3145
                    return;
UNCOV
3146
                }
×
3147
            } catch (e) {
3148
                this.logger.error('Error attempting to exit application', e);
3149
            }
3150

3151
            await util.sleep(200);
3152
        }
16✔
3153
    }
3154

16!
3155
    /**
16✔
3156
     * Used to track whether the entry breakpoint has already been handled
3157
     */
3158
    private entryBreakpointWasHandled = false;
16✔
UNCOV
3159

×
UNCOV
3160
    /**
×
3161
     * Registers the main events for the RokuAdapter
3162
     */
16!
3163
    private async connectRokuAdapter() {
16✔
3164
        this.rokuAdapter.on('start', () => {
16✔
3165
            this.sendLaunchProgress('end', 'Complete');
3166
            if (!this.firstRunDeferred.isCompleted) {
3167
                this.firstRunDeferred.resolve();
UNCOV
3168
            }
×
3169
        });
3170

16✔
3171
        this.rokuAdapter.on('launch-status', (message) => {
3172
            this.sendLaunchProgress('update', message);
×
3173
        });
3174

3175
        //when the debugger suspends (pauses for debugger input)
16✔
3176
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3177
        this.rokuAdapter.on('suspend', async () => {
16✔
3178
            await this.onSuspend();
16!
UNCOV
3179
        });
×
UNCOV
3180

×
UNCOV
3181
        //anytime the adapter encounters an exception on the roku,
×
3182
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3183
        this.rokuAdapter.on('runtime-error', async (exception) => {
3184
            await this.getRokuAdapter();
3185
            const threads = await this.setupSuspendedState();
3186
            let threadId = threads[0]?.threadId;
×
3187
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
3188
        });
3189

16✔
3190
        // If the roku says it can't continue, we are no longer able to debug, so kill the debug session
16✔
3191
        this.rokuAdapter.on('cannot-continue', () => {
3192
            this.shutdown().catch(e => this.logger.error(e));
3193
        });
16✔
3194

3195
        //make the connection
16✔
3196
        await this.rokuAdapter.connect();
16!
3197
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
3198
        return this.rokuAdapter;
UNCOV
3199
    }
×
3200

3201
    private async onSuspend() {
3202
        const threads = await this.setupSuspendedState();
16✔
3203
        const activeThread = threads.find(x => x.isSelected);
16✔
3204

16!
3205
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3206
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
16✔
3207
            this.entryBreakpointWasHandled = true;
16✔
3208
            //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
16✔
3209
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
3210
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
3211
                return this.rokuAdapter.continue();
×
3212
            }
3213
        }
3214

UNCOV
3215
        const event: StoppedEvent = new StoppedEvent(
×
3216
            StoppedEventReason.breakpoint,
3217
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
16✔
3218
            activeThread?.threadId ?? 0,
16!
3219
            '' //exception text
3220
        );
UNCOV
3221
        // Socket debugger will always stop all threads and supports multi thread inspection.
×
3222
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
3223
        this.sendEvent(event);
16✔
3224
    }
16!
3225

3226
    private async setupSuspendedState() {
UNCOV
3227
        //clear the index for storing evalutated expressions
×
3228
        this.evaluateVarIndexByFrameId.clear();
3229

16✔
3230
        const threads = await this.rokuAdapter.getThreads();
16!
3231

3232
        //TODO remove this once Roku fixes their threads off-by-one line number issues
UNCOV
3233
        //look up the correct line numbers for each thread from the StackTrace
×
3234
        await Promise.all(
3235
            threads.map(async (thread) => {
16✔
3236
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
16!
3237
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
3238
                const stackTraceFilePath = stackTrace[0]?.filePath;
UNCOV
3239
                // Only apply the line correction when we actually have valid data — never clobber
×
3240
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3241
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
16✔
3242
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
3243
                    thread.lineNumber = stackTraceLineNumber;
16!
3244
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
16!
3245
                }
16✔
3246
            })
16✔
3247
        );
2✔
3248

2✔
3249
        outer: for (const bp of this.breakpointManager.failedDeletions) {
3250
            for (const thread of threads) {
UNCOV
3251
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
×
UNCOV
3252
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
×
3253
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3254
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
3255
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
3256
                        this.logger.error('Error showing popup message', { error });
3257
                    });
UNCOV
3258
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
×
3259
                    break outer;
3260
                }
16✔
3261
            }
16✔
3262
        }
16✔
3263

3264
        //sync breakpoints
16✔
3265
        await this.rokuAdapter?.syncBreakpoints();
16✔
3266

16✔
3267
        this.logger.info('received "suspend" event from adapter');
3268

UNCOV
3269
        this.clearState();
×
3270
        return threads;
3271
    }
16✔
3272

16✔
3273
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
3274
        let v: AugmentedVariable;
UNCOV
3275

×
3276
        if (result) {
3277
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
3278
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
3279
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
2✔
3280
                    try {
2✔
3281
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3282
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
3283
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
3284
                        this.mergeEvaluateContainers(result, newResult);
3285
                    } catch (error) {
3286
                        logger.error('Error getting variables', error);
3287
                        this.mergeEvaluateContainers(result, {
3288
                            name: result.name,
3289
                            evaluateName: result.evaluateName,
3290
                            children: [],
3291
                            value: `❌ Error: ${error.message}`,
3292
                            type: '',
3293
                            highLevelType: undefined,
3294
                            keyType: undefined
3295
                        });
3296
                    }
3297
                }
3298

3299
                if (result.keyType) {
3300
                    let value = `${result.value ?? result.type}`;
3301
                    let indexedVariables = result.indexedVariables;
3302
                    let namedVariables = result.namedVariables;
3303

3304
                    if (indexedVariables === undefined || namedVariables === undefined) {
3305
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3306
                        // by supplying undefined values for both
3307
                        indexedVariables = undefined;
3308
                        namedVariables = undefined;
3309
                    }
3310

3311
                    // check to see if this is an dictionary or a list
3312
                    if (result.keyType === 'Integer') {
3313
                        // list type
3314
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
3315
                    } else if (result.keyType === 'String') {
3316
                        // dictionary type
3317
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
3318
                    }
3319
                    v.type = result.type;
3320
                } else {
3321

3322
                    let value: string;
3323
                    if (result.type === VariableType.Invalid) {
3324
                        value = result.value ?? 'Invalid';
3325
                    } else if (result.type === VariableType.Uninitialized) {
3326
                        value = 'Uninitialized';
3327
                    } else {
3328
                        value = `${result.value}`;
3329
                    }
3330
                    // If the variable is lazy we must assign a refId to inform the system
3331
                    // to request this variable again in the future for value resolution
3332
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
3333
                }
3334
                this.variables[refId] = v;
3335
            } else if (this.rokuAdapter.isTelnetAdapter()) {
3336
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
3337
                    v = new Variable(result.name, `${result.value}`);
3338
                } else if (result.highLevelType === 'array') {
3339
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
3340
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
3341
                    this.variables[refId] = v;
3342
                } else if (result.highLevelType === 'object') {
3343
                    let refId: number;
3344
                    //handle collections
3345
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
3346
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
3347
                    }
3348
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
3349
                    this.variables[refId] = v;
3350
                } else if (result.highLevelType === 'function') {
3351
                    v = new Variable(result.name, `${result.value}`);
3352
                } else {
3353
                    //all other cases, but mostly for HighLevelType.unknown
3354
                    v = new Variable(result.name, `${result.value}`);
3355
                }
3356
            }
3357

3358
            v.type = result.type;
3359
            v.evaluateName = result.evaluateName;
3360
            v.frameId = frameId;
3361
            v.type = result.type;
3362
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
3363
            if (util.isTransientVariable(v.name)) {
3364
                v.presentationHint = { kind: 'virtual' };
3365
            }
3366

3367
            if (result.children && maxDepth > 0) {
3368
                if (!v.childVariables) {
3369
                    v.childVariables = [];
3370
                }
3371

3372
                // Create a mapping of the children to their index so we can evaluate them in bulk
3373
                let indexMappedChildren = result.children.map((child, index) => {
3374
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
3375
                    return remapped;
3376
                });
3377
                if (this.enableDebugProtocol) {
3378
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
3379
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
3380
                        return { expression: x.child.evaluateName, frameId: frameId };
3381
                    });
3382

3383
                    let variablePathArray = childrenToEvaluate.map(x => {
3384
                        return util.getVariablePath(x.child.evaluateName);
3385
                    });
3386

3387
                    try {
3388
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
3389
                        if (bulkEvaluations.bulkVarName) {
3390
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
3391
                            childrenToEvaluate.map((mappedChild, index) => {
3392
                                let newResult = newResults.children[index];
3393
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
3394
                                mappedChild.child.evaluateNow = false;
3395
                                return mappedChild;
3396
                            });
3397
                        }
3398
                    } catch (error) {
3399
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
3400
                    }
3401
                }
3402
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3403
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
3404
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
3405
                }));
3406
            } else {
3407
                v.childVariables = [];
3408
            }
3409

3410
            // if the var is an array and debugProtocol is enabled, include the array size
3411
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
3412
                if (isNaN(result.indexedVariables as number)) {
3413
                    v.value = v.type;
3414
                } else {
3415
                    v.value = `${v.type}(${result.indexedVariables})`;
3416
                }
3417
            }
3418
        }
3419
        return v;
3420
    }
3421

3422
    /**
3423
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3424
     * Used primarily for custom variables
3425
     */
3426
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
3427
        original.children = updated.children;
3428
        original.value = updated.value;
3429
        original.type = updated.type;
3430
        original.highLevelType = updated.highLevelType;
3431
        original.keyType = updated.keyType;
3432
        original.indexedVariables = updated.indexedVariables;
3433
        original.namedVariables = updated.namedVariables;
3434
    }
3435

3436
    private getEvaluateRefId(expression: string, frameId: number) {
3437
        let evaluateRefId = `${expression}-${frameId}`;
3438
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
3439
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
3440
        }
3441
        return this.evaluateRefIdLookup[evaluateRefId];
3442
    }
3443

3444
    private clearState() {
3445
        //erase all cached variables
3446
        this.variables = {};
3447
        this.completionParentVariableCache.clear();
3448
    }
3449

3450
    /**
3451
     * Sends a launch progress event to the client if the client supports progress reporting.
3452
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3453
     * - `'update'`: updates the message on the active progress bar.
3454
     * - `'end'`: dismisses the active progress bar with an optional final message.
3455
     */
3456
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3457
        if (!this.initRequestArgs?.supportsProgressReporting) {
3458
            return;
3459
        }
3460
        if (type === 'start') {
3461
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
3462
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
3463
        } else if (this.launchProgressId) {
3464
            if (type === 'update') {
3465
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
3466
            } else {
3467
                const lastId = this.launchProgressId;
3468
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
3469
                setTimeout(() => {
3470
                    this.sendEvent(new ProgressEndEvent(lastId, message));
3471
                }, 1000); // add a slight delay before ending the progress to improve UX
3472
                this.launchProgressId = undefined;
3473
            }
3474
        }
3475
    }
3476

3477
    /**
3478
     * Tells the client to re-request all variables because we've invalidated them
3479
     * @param threadId
3480
     * @param stackFrameId
3481
     */
3482
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3483
        //if the client supports this request, send it
3484
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3485
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
3486
        }
3487
    }
3488

3489
    /**
3490
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3491
     */
3492
    public async handleEntryBreakpoint() {
3493
        if (!this.enableDebugProtocol) {
3494
            this.entryBreakpointWasHandled = true;
3495
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
3496
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
3497
            }
3498
        }
3499
    }
3500

3501
    /**
3502
     * Converts a debugger line number to a client line number.
3503
     *
3504
     * @param debuggerLine - The line number from the debugger as zero based.
3505
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3506
     * @returns The corresponding client line number.
3507
     */
3508
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3509
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3510
    }
3511

3512
    /**
3513
     * Converts a debugger column number to a client column number.
3514
     *
3515
     * @param debuggerLine - The column number from the debugger as zero based.
3516
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3517
     * @returns The corresponding client column number.
3518
     */
3519
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3520
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3521
    }
3522

3523
    /**
3524
     * Converts a client line number to a debugger line number.
3525
     *
3526
     * @param clientLine - The line number from the client.
3527
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3528
     * @returns The corresponding debugger line number as zero based.
3529
     */
3530
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3531
        if (typeof clientLine === 'number') {
3532
            return this.convertClientLineToDebugger(clientLine);
3533
        }
3534
        return defaultDebuggerLine;
3535
    }
3536

3537
    /**
3538
     * Converts a client column number to a debugger column number.
3539
     *
3540
     * @param clientLine - The column number from the client.
3541
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3542
     * @returns The corresponding debugger column number as zero based.
3543
     */
3544
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3545
        if (typeof clientLine === 'number') {
3546
            return this.convertClientColumnToDebugger(clientLine);
3547
        }
3548
        return defaultDebuggerLine;
3549
    }
3550

3551
    private shutdownPromise: Promise<void> | undefined = undefined;
3552

3553
    /**
3554
     * 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
3555
     * the same promise on subsequent calls
3556
     */
3557
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
3558
        if (this.shutdownPromise === undefined) {
3559
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
3560
            //Backstop: if the graceful shutdown hangs (e.g. pressHomeButton against an unreachable
3561
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3562
            const forceExitTimer = setTimeout(() => {
3563
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
3564
                this.forceExit();
3565
            }, this.shutdownForceExitTimeout);
3566
            forceExitTimer.unref?.();
3567
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
3568
                clearTimeout(forceExitTimer);
3569
            });
3570
        } else {
3571
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
3572
        }
3573
        return this.shutdownPromise;
3574
    }
3575

3576
    private async _shutdown(errorMessage?: string, modal = false): Promise<void> {
3577
        // Ensure any active launch progress bar is dismissed before showing error messages or the terminated event.
3578
        this.sendLaunchProgress('end', 'Complete');
3579

3580
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3581
        try {
3582
            if (errorMessage) {
3583
                this.logger.error(errorMessage);
3584
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
3585
                    this.logger.error('Error showing popup message', { error });
3586
                });
3587
            }
3588
        } catch (e) {
3589
            this.logger.error(e);
3590
        }
3591
        // stop perfetto tracing if it's running
3592
        try {
3593
            await this.perfettoManager.stopTracing();
3594
        } catch (e) {
3595
            this.logger.error('Error stopping perfetto tracing', e);
3596
        }
3597

3598
        try {
3599
            await this.perfettoManager?.dispose?.();
3600
        } catch (e) {
3601
            this.logger.error('Error disposing perfetto manager', e);
3602
        }
3603

3604
        //close the debugger connection
3605
        try {
3606
            this.logger.log('Destroy rokuAdapter');
3607
            await this.rokuAdapter?.destroy?.();
3608
            //press the home button to return to the home screen
3609
            try {
3610
                this.logger.log('Press home button');
3611
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
3612
            } catch (e) {
3613
                this.logger.error(e);
3614
            }
3615
        } catch (e) {
3616
            this.logger.error(e);
3617
        }
3618

3619
        try {
3620
            this.projectManager?.dispose?.();
3621
        } catch (e) {
3622
            this.logger.error(e);
3623
        }
3624

3625
        try {
3626
            this.componentLibraryServer?.stop();
3627
        } catch (e) {
3628
            this.logger.error(e);
3629
        }
3630

3631
        try {
3632
            await this.rendezvousTracker?.destroy?.();
3633
        } catch (e) {
3634
            this.logger.error(e);
3635
        }
3636

3637
        try {
3638
            await this.sourceMapManager?.destroy?.();
3639
        } catch (e) {
3640
            this.logger.error(e);
3641
        }
3642

3643
        try {
3644
            //if configured, delete the staging directory
3645
            if (!this.launchConfiguration.retainStagingFolder) {
3646
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
3647
                this.logger.info('deleting staging folders', stagingDirs);
3648
                for (let stagingDir of stagingDirs) {
3649
                    try {
3650
                        fsExtra.removeSync(stagingDir);
3651
                    } catch (e) {
3652
                        this.logger.error(e);
3653
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
3654
                    }
3655
                }
3656
            }
3657
        } catch (e) {
3658
            this.logger.error(e);
3659
        }
3660

3661
        try {
3662
            this.logger.log('Send terminated event');
3663
            this.sendEvent(new TerminatedEvent());
3664

3665
            //shut down the process
3666
            this.logger.log('super.shutdown()');
3667
            super.shutdown();
3668
            this.logger.log('shutdown complete');
3669
        } catch (e) {
3670
            this.logger.error(e);
3671
        }
3672

3673
        try {
3674
            this.teardownProcessErrorHandlers();
3675
        } catch (e) {
3676
            this.logger.error(e);
3677
        }
3678
    }
3679
}
3680

3681
export interface AugmentedVariable extends DebugProtocol.Variable {
3682
    childVariables?: AugmentedVariable[];
3683
    // eslint-disable-next-line camelcase
3684
    request_seq?: number;
3685
    frameId?: number;
3686
    /**
3687
     * only used for lazy variables
3688
     */
3689
    isResolved?: boolean;
3690
    /**
3691
     * used to indicate that this variable is a scope variable
3692
     * and may require special handling
3693
     */
3694
    isScope?: boolean;
3695
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc