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

rokucommunity / roku-debug / 28820148876

06 Jul 2026 08:12PM UTC coverage: 73.015% (+0.3%) from 72.68%
28820148876

Pull #323

github

web-flow
Merge 3e79a64cf into 1f928695c
Pull Request #323: apply postfix for Library statement

3842 of 5489 branches covered (69.99%)

Branch coverage included in aggregate %.

79 of 105 new or added lines in 2 files covered. (75.24%)

354 existing lines in 1 file now uncovered.

6007 of 8000 relevant lines covered (75.09%)

47.39 hits per line

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

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

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

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

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

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

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

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

113
    public start(inStream: NodeJS.ReadableStream, outStream: NodeJS.WritableStream): void {
114
        super.start(inStream, outStream);
3✔
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
118
        //failure re-triggers shutdown() in a tight loop that pegs the CPU and orphans this process.
119
        const markClientGone = () => {
3✔
120
            this.clientDisconnected = true;
1✔
121
        };
122
        inStream?.on?.('close', markClientGone);
3✔
123
        inStream?.on?.('end', markClientGone);
3✔
124
        outStream?.on?.('error', markClientGone);
3✔
125

126
        // Set up DAP protocol logging as early as possible — immediately after start() so we capture
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,
129
        // which resolves the path from the `brightscript.debug.debugAdapterProtocolLogging` workspace setting
130
        // (or the equivalent launch.json property) before the debug adapter process is spawned.
131
        const dapLogFile = process.env.ROKU_DAP_LOG_FILE;
3✔
132
        if (dapLogFile) {
3✔
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,
137
            // so the log file will still contain everything.
138
            dapLogger.setup(DapLogger.LogLevel.Error, dapLogFile);
1✔
139
        }
140
    }
141

142
    /**
143
     * Once the client has disconnected, drop outgoing events instead of writing to the dead stream.
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) {
674✔
148
            return;
4✔
149
        }
150
        super.sendEvent(event);
670✔
151
    }
152

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

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

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

166
    private handlingProcessError = false;
215✔
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
     */
172
    private isClientGoneError(error: unknown): boolean {
173
        const code = (error as NodeJS.ErrnoException)?.code;
21!
174
        const message = error instanceof Error ? error.message : String(error ?? '');
21!
175
        return code === 'EPIPE' || /\bEPIPE\b|write after end/i.test(message);
21✔
176
    }
177

178
    /**
179
     * Tear down the process error handlers and forcibly exit, so we never leave an orphaned adapter
180
     * spinning in the background after the client is gone or a graceful shutdown has hung.
181
     */
182
    private forceExit(code = 0): void {
1✔
183
        this.teardownProcessErrorHandlers();
1✔
184
        process.exit(code);
1✔
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
189
        //the now-dead stream just produces more EPIPEs, which re-enter this handler in a tight loop and
190
        //peg the CPU. Exit instead.
191
        if (this.isClientGoneError(error)) {
18✔
192
            this.clientDisconnected = true;
1✔
193
            this.forceExit();
1✔
194
            return;
1✔
195
        }
196
        //only handle the first error; re-entering here (e.g. from a failed write while reporting) would
197
        //spin the CPU and flood the logs
198
        if (this.handlingProcessError) {
17✔
199
            return;
2✔
200
        }
201
        this.handlingProcessError = true;
15✔
202

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

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

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

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

226
            const lines = Object.entries(additionalInfo as Record<string, unknown>).map(([key, value]) => {
14✔
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');
98✔
229
                const formattedKey = spacedString.charAt(0).toUpperCase() + spacedString.slice(1);
98✔
230
                return `**${formattedKey}:** ${JSON.stringify(value)}`;
98✔
231
            });
232

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

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

265
            output = [
11✔
266
                '',
267
                '================================================================',
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}`),
77✔
274
                '',
275
                '\tStack trace:',
276
                ...(stack ?? '(no stack trace)').split('\n').map(l => `\t${l}`),
84✔
277
                '',
278
                '\tPlease report this at:',
279
                `\t${issueUrl}`,
280
                '================================================================',
281
                ''
282
            ].join('\n');
283
        } catch (e) {
284
            output = JSON.stringify({
4✔
285
                name: e.name,
286
                message: e.message,
287
                stack: e.stack
288
            });
289
        }
290

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

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

309
    private onDeviceBreakpointsChanged(eventName: 'changed' | 'new', data: { breakpoints: AugmentedSourceBreakpoint[] }) {
310
        this.logger.info('Sending verified device breakpoints to client', data);
3✔
311
        //send all verified breakpoints to the client
312
        for (const breakpoint of data.breakpoints) {
3✔
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,
319
                message: breakpoint.message,
320
                source: {
321
                    path: breakpoint.srcPath
322
                }
323
            };
324
            this.sendEvent(new BreakpointEvent(eventName, event));
3✔
325
        }
326
    }
327

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

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

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

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
342

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

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

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

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

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

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

379
    /**
380
     * Caches the device lookups performed while resolving completion requests. Variables don't change
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.
383
     */
384
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
215✔
385

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
387

388
    private perfettoManager: PerfettoManager;
389

390
    private rendezvousTracker: RendezvousTracker;
391

392
    public tempVarPrefix = '__rokudebug__';
215✔
393

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

400
    /**
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

405
    /**
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;
215✔
409

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

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

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

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

434
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
215✔
435

436
    /**
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 {
441
        this.initRequestArgs = args;
2✔
442
        this.logger.log('initializeRequest');
2✔
443

444
        response.body ||= {};
2✔
445

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

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.
450
        response.body.supportsRestartRequest = true;
2✔
451

452
        // make VS Code to use 'evaluate' when hovering over source
453
        response.body.supportsEvaluateForHovers = true;
2✔
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
459
        //take effect immediately - unlike `exceptionBreakpointFilters` / `breakpointModes` which
460
        //must be in the initialize response.
461

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.
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 = [{
2✔
467
            filter: 'caught',
468
            supportsCondition: true,
469
            conditionDescription: '__brs_err__.rethrown = true',
470
            label: 'Caught Exceptions',
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
        }];
481

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

485
        this.sendResponse(response);
2✔
486

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

496
        this.logger.log('initializeRequest finished');
2✔
497
    }
498

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

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

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
518
            //respond to this request.
519
            await this.rokuAdapterDeferred.promise;
9✔
520

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

547

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

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

561
    private static requestIdSequence = 0;
2✔
562

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

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

592
    public deviceInfo: DeviceInfo;
593

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

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

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

632
        try {
9✔
633
            this.resetSessionState();
9✔
634
            this.launchConfiguration = this.normalizeLaunchConfig(config);
9✔
635
            this.setupProcessErrorHandlers();
9✔
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) {
9!
642
                logger.logLevel = this.launchConfiguration.logLevel;
×
643
            }
644

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

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

654
            // fetch device info if not supplied via launch config
655
            try {
9✔
656
                if (this.launchConfiguration.deviceInfo) {
9✔
657
                    this.deviceInfo = rokuDeploy.enhanceDeviceInfo(this.launchConfiguration.deviceInfo);
1✔
658
                } else {
659
                    this.deviceInfo = await rokuDeploy.getDeviceInfo({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
8✔
660
                }
661
                if (this.deviceInfo.ecpSettingMode === 'limited') {
9!
NEW
662
                    return await this.shutdown(`ECP access is limited on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
663
                }
664
            } catch (e) {
NEW
665
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
NEW
666
                    return this.shutdown(`ECP access is disabled on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
667
                }
NEW
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.`);
×
669
            }
670

671
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
9!
NEW
672
                return await this.shutdown(`Developer mode is not enabled for host '${this.launchConfiguration.host}'.`);
×
673
            }
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);
9✔
677

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

681
            this.projectManager.launchConfiguration = this.launchConfiguration;
9✔
682
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
9✔
683

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

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

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

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) {
9!
UNCOV
700
                await this.sendCustomRequest('processStagingDir', {
×
701
                    projects: this.projectManager.getProjectStagingInfo()
702
                });
703
            }
704

705
            packageEnd();
9✔
706

707
            if (this.enableDebugProtocol) {
9!
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}`);
9✔
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 {
9✔
715
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
9✔
716
                await this.initRendezvousTracking();
9✔
717
                rendezvousEnd();
9✔
718
            } catch (e) {
UNCOV
719
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
720
            }
721

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

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

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

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).
744
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
745
            // effect dynamically.
746
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
9✔
747
            this.sendEvent(new CapabilitiesEvent({
9✔
748
                supportsLogPoints: !this.enableDebugProtocol,
749
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
750
                supportsExceptionOptions: supportsExceptionBreakpoints,
751
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
752
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
753
            }));
754

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

757
            util.log('Done initializing');
9✔
758

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

763
            await this.initializeProfiling();
9✔
764

765
        } catch (e) {
766
            //if the message is anything other than compile errors, we want to display the error
UNCOV
767
            if (!(e instanceof CompileError)) {
×
UNCOV
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
UNCOV
772
                await this.rokuAdapter?.sendErrors();
×
773

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

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

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

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

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

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

808
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
4✔
UNCOV
809
                const stopDebuggerAction = 'Stop Debugger';
×
NEW
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)}"` : '');
×
UNCOV
812
                this.logger.log(message, data);
×
UNCOV
813
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
UNCOV
814
                if (response === stopDebuggerAction) {
×
UNCOV
815
                    await this.shutdown();
×
816
                }
817
            });
818

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

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

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

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

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

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

862
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
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([
4✔
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.
872
            await this.projectManager.applyLibraryReferencePostfixes();
4✔
873

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

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

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

890
            //tell the adapter adapter that the channel has been launched.
891
            this.sendLaunchProgress('update', 'Waiting on application');
3✔
892
            await this.rokuAdapter.activate();
3✔
893
            if (this.rokuAdapter.isDestroyed) {
3!
UNCOV
894
                throw new Error('Debug session encountered an error');
×
895
            }
896
            if (!error) {
3!
897
                if (this.rokuAdapter.connected) {
3!
898
                    this.logger.info('Host connection was established before the main public process was completed');
3✔
899
                    this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
3✔
900
                } else {
UNCOV
901
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
902
                    this.rokuAdapter.on('connected', (status) => {
×
UNCOV
903
                        if (status) {
×
UNCOV
904
                            this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
×
905
                        }
906
                    });
907
                }
908
            } else {
UNCOV
909
                throw error;
×
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) {
3!
914
                //wait until the first entry breakpoint has been hit
UNCOV
915
                await this.firstRunDeferred.promise;
×
916
                //if we are at a breakpoint, continue
UNCOV
917
                await this.rokuAdapter.continue();
×
918
                //kill the app on the roku
919
                // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
920
                //convert a hostname to an ip address
UNCOV
921
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
922
                //send the deep link http request
UNCOV
923
                await util.httpPost(deepLinkUrl);
×
924
            }
925

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

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

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

943
    /**
944
     * Activate all required functionality for profiling
945
     */
946
    private async initializeProfiling() {
947

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

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

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

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

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

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');
12✔
1003
        }
1004
    }
1005

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

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

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

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

1056
    private async _initRendezvousTracking() {
1057
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
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) => {
3✔
1061
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
1062
        });
1063

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

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

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

1079
    /**
1080
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1081
     */
1082
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1083
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1084
        for (let diagnostic of diagnostics) {
2✔
1085
            diagnostic.source = diagnosticSource;
2✔
1086
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2✔
1087
            if (sourceLocation) {
2✔
1088
                diagnostic.path = sourceLocation.filePath;
1✔
1089
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1090
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1091
            } else {
1092
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1093
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1✔
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);
2✔
1099
        if (this.compileError) {
2✔
1100
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
1101
            this.sendEvent(new StoppedEvent(
1✔
1102
                StoppedEventReason.exception,
1103
                this.COMPILE_ERROR_THREAD_ID,
1104
                `CompileError: ${this.compileError.message}`
1105
            ));
1106
        }
1107

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

1111
    private publishTimeout = 60_000;
215✔
1112

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

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

1134
        const isConnected = this.rokuAdapter.once('app-ready');
2✔
1135
        const options: RokuDeployOptions = {
2✔
1136
            ...this.launchConfiguration,
1137
            //typing fix
1138
            logLevel: LogLevelPriority[this.logger.logLevel],
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)
1142
            remoteDebugConnectEarly: false,
1143
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1144
            failOnCompileError: true,
1145
            //pass any upload form overrides the client may have configured
1146
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1147
        };
1148
        //if packagePath is specified, use that info instead of outDir and outFile
1149
        if (this.launchConfiguration.packagePath) {
2✔
1150
            options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1151
            options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1152
        }
1153

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

1167
        await publishPromise;
2✔
1168

1169
        uploadingEnd();
2✔
1170

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

1190
    private pendingSendLogPromise = Promise.resolve();
215✔
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) {
38!
UNCOV
1198
            return Promise.resolve();
×
1199
        }
1200
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38✔
1201

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

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

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

1231
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1232
                        }
1233
                    }
1234
                }
1235
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1236
                this.sendEvent(new LogOutputEvent(line));
264✔
1237
            }
1238
        });
1239
        return this.pendingSendLogPromise;
38✔
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
1246
     * and returns an array of objects containing details about each match.
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.
1254
     */
1255
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1256
        // https://regex101.com/r/ixpQiq/1
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);
91✔
1258
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91✔
1259
        if (matches) {
91!
1260
            for (let match of matches) {
91✔
1261
                let fullMatch = match[0];
28✔
1262
                let path = match[1];
28✔
1263
                let lineNumber = parseInt(match[2] ?? match[4]);
28✔
1264
                let columnNumber = parseInt(match[3] ?? match[5]);
28✔
1265
                if (isNaN(columnNumber)) {
28✔
1266
                    columnNumber = undefined;
17✔
1267
                }
1268
                paths.push({
28✔
1269
                    fullMatch: fullMatch,
1270
                    path: path,
1271
                    lineNumber: lineNumber,
1272
                    columnNumber: columnNumber
1273
                });
1274
            }
1275
        }
1276
        return paths;
91✔
1277
    }
1278

1279
    /**
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) {
1283
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38✔
1284
            return input;
8✔
1285
        }
1286
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
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;
30✔
1291
        let matches = [];
30✔
1292
        let match = deviceBacktraceObjectRegex.exec(input);
30✔
1293
        while (match) {
30✔
1294
            matches.push(match);
5✔
1295
            match = deviceBacktraceObjectRegex.exec(input);
5✔
1296
        }
1297

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

1317
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
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
1320
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1321
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1322
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1323
                }
1324

1325
            }
1326
        }
1327

1328
        return input;
30✔
1329
    }
1330

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

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

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

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

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

1380
    /**
1381
     * Stage, insert breakpoints, and package the main project
1382
     */
1383
    public async prepareMainProject() {
1384
        //add the main project
1385
        this.projectManager.mainProject = new Project({
2✔
1386
            rootDir: this.launchConfiguration.rootDir,
1387
            files: this.launchConfiguration.files,
1388
            outDir: this.launchConfiguration.outDir,
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,
1394
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1395
            stagingDir: this.launchConfiguration.stagingDir,
1396
            packagePath: this.launchConfiguration.packagePath,
1397
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1398
        });
1399

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

1403
        //add the entry breakpoint if stopOnEntry is true
1404
        await this.handleEntryBreakpoint();
2✔
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');
2✔
1416

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

1421
    /**
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
     */
1425
    private async zipMainProject() {
1426
        if (this.launchConfiguration.packageTask) {
2✔
1427
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1428
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1429

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

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

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

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

UNCOV
1463
        } else if (command === 'popupMessageEventResponse') {
×
NEW
1464
            this.emit('popupMessageEventResponse', args);
×
1465

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

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

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

1485
        }
NEW
1486
        this.sendResponse(response);
×
1487
    }
1488

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) {
11✔
1494
            return;
2✔
1495
        }
1496
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1497
        //make sure this folder exists (and is empty)
1498
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1499
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1500

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

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

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

1529
    /**
1530
     * Inject breakpoint STOPs into the staged complibs and postfix each library's own files. Runs in
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.
1534
     */
1535
    protected async writeAndPostfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1536
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1537
            return;
2✔
1538
        }
1539

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

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

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

1563
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9✔
1564
        if (needToDeleteComplibs) {
8✔
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.
1568
            await this.deleteAllComponentLibraries();
7✔
1569
        }
1570

1571
        //zip each complib in parallel
1572
        const packagePromises = this.projectManager.componentLibraryProjects.map(
8✔
1573
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
18✔
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++) {
8✔
1581
            const compLibProject = this.projectManager.componentLibraryProjects[i];
18✔
1582

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

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

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

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

1608
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
12✔
1609
                try {
12✔
1610
                    await rokuDeploy.publish(options);
12✔
1611
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
11✔
1612
                } catch (error) {
1613
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
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);
1✔
1616
                    throw error;
1✔
1617
                }
1618
            }
1619
        }
1620

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

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

1632
    /**
1633
     * Delete every component library installed on the device.
1634
     *
1635
     * A complib can only depend on complibs the user declared BEFORE it, so the user's configured order is already
1636
     * dependency order. Deleting in the REVERSE of that order therefore deletes each complib before the ones it
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
1640
     * order, so we fall back to compile-error tolerance for those: deleting a complib while another still references
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
     */
1644
    private async deleteAllComponentLibraries() {
1645
        const deviceOptions = {
16✔
1646
            host: this.launchConfiguration.host,
1647
            password: this.launchConfiguration.password,
1648
            username: this.launchConfiguration.username || 'rokudev'
32✔
1649
        };
1650

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

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

1668
        const maxAttempts = 5;
16✔
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>();
16✔
1671
        const failed = new Set<string>();
16✔
1672

1673
        while (true) {
16✔
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();
39✔
1676

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

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));
40✔
1683
            const next = candidates.sort((a, b) =>
39✔
1684
                attemptCount(a) - attemptCount(b) ||
15✔
1685
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
1686
            )[0];
1687

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

1696
            const fileName = next.archiveFileName;
24✔
1697
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
24✔
1698
            try {
24✔
1699
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
24✔
1700
            } catch (error) {
1701
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
1702
                if (!this.isComponentLibraryDependencyCompileError(error)) {
9✔
1703
                    throw error;
1✔
1704
                }
1705
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
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.
1708
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
8!
UNCOV
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) {
8✔
1714
                        failed.add(fileName);
1✔
1715
                    }
1716
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
8✔
1717
                }
1718
            }
1719

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

1725
    /**
1726
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
1727
     * installed complib still references the one we tried to delete)?
1728
     */
1729
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1730
        const message = `${error?.message ?? ''}`;
9!
1731
        return /compile error|compilation failed/i.test(message);
9✔
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.
1739
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
1740
     */
1741
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
1742
        const successes: string[] = error?.results?.successes ?? [];
8!
1743
        return successes.some(message => /delete succeeded/i.test(message));
8✔
1744
    }
1745

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

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

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

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

1773
        await this.rokuAdapter?.syncBreakpoints();
6!
1774
    }
1775

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

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

1783
        let threads = [];
4✔
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) {
4!
UNCOV
1787
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1788
        } else {
1789
            //wait for the roku adapter to load
1790
            await this.getRokuAdapter();
4✔
1791

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

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

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

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

1815
        }
1816

1817
        response.body = {
4✔
1818
            threads: threads
1819
        };
1820

1821
        this.sendResponse(response);
4✔
1822
    }
1823

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

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

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

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

1860
        return threadName;
24✔
1861
    }
1862

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

1893
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1894
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1895
                    if (stackTrace.length === 0) {
3✔
1896
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1897
                        // the thread without letting the user navigate to a source location
1898
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1899
                        frame.presentationHint = 'label';
2✔
1900
                        frames.push(frame);
2✔
1901
                    } else {
1902
                        for (let debugFrame of stackTrace) {
1✔
1903
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
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
1907
                            try {
3✔
1908
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1909
                                if (functionName) {
3!
1910

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

1927
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1928
                                debugFrame.frameId,
1929
                                `${debugFrame.functionIdentifier}`,
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),
18!
1933
                                this.toClientColumn(0)
1934
                            );
1935
                            if (!sourceLocation) {
3!
UNCOV
1936
                                frame.presentationHint = 'subtle';
×
1937
                            }
1938
                            frames.push(frame);
3✔
1939
                        }
1940
                    }
1941
                } else {
1942
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1943
                }
1944
            }
1945
            response.body = {
3✔
1946
                stackFrames: frames,
1947
                totalFrames: frames.length
1948
            };
1949
            this.sendResponse(response);
3✔
1950
        } catch (error) {
UNCOV
1951
            this.logger.error('Error getting stacktrace', { error, args });
×
1952
        }
1953
    }
1954

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

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

1964
            let localScope: DebugProtocol.Scope = {
1✔
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,
1969
                presentationHint: 'locals'
1970
            };
1971

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

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

UNCOV
1984
            scopes.push(localScope);
×
1985

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

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

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

2014
    /**
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`.
2017
     */
2018
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
2019
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
2020
        if (!this.variables[refId]) {
5✔
2021
            this.variables[refId] = {
1✔
2022
                variablesReference: refId,
2023
                name: 'Locals',
2024
                value: '',
2025
                type: '$$Locals',
2026
                frameId: frameId,
2027
                isScope: true,
2028
                childVariables: []
2029
            };
2030
        }
2031
        return this.variables[refId];
5✔
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
×
UNCOV
2044
        this.clearState();
×
2045

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

2051
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
UNCOV
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();
×
UNCOV
2058
            return;
×
2059
        }
2060

2061
        await this.rokuAdapter.pause();
×
UNCOV
2062
        this.sendResponse(response);
×
2063
    }
2064

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

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

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

UNCOV
2085
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
2086
        this.clearState();
×
2087

2088
        // The debug session ends after the next line. Do not put new work after this line.
2089
        try {
×
2090
            await this.rokuAdapter.stepOver(args.threadId);
×
2091
            this.logger.info('[nextRequest] end');
×
2092
        } catch (error) {
UNCOV
2093
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
2094
        }
UNCOV
2095
        this.sendResponse(response);
×
2096
    }
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
UNCOV
2102
        if (this.compileError) {
×
UNCOV
2103
            this.sendResponse(response);
×
UNCOV
2104
            await this.shutdown();
×
UNCOV
2105
            return;
×
2106
        }
2107

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

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

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

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

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

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

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

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

2151
            let updatedVariables: AugmentedVariable[] = [];
4✔
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;
4✔
2154
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
2155
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
UNCOV
2156
                response.success = false;
×
UNCOV
2157
                response.message = 'Debug session is not paused';
×
UNCOV
2158
                return this.sendResponse(response);
×
2159
            }
2160

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

2170
            // Populate scope level values if needed
2171
            if (v.isScope) {
4✔
2172
                await this.populateScopeVariables(v, args);
2✔
2173
            }
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) {
4!
2177
                let tempVar: AugmentedVariable;
2178
                if (!v.isResolved) {
×
2179
                    // Evaluate the variable
2180
                    try {
×
UNCOV
2181
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
UNCOV
2182
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
UNCOV
2183
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
UNCOV
2184
                        tempVar.frameId = v.frameId;
×
2185
                        // Determine if the variable has changed
2186
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
2187
                    } catch (error) {
UNCOV
2188
                        logger.error('Error getting variables', error);
×
UNCOV
2189
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
UNCOV
2190
                        tempVar.type = '';
×
2191
                        tempVar.childVariables = [];
×
UNCOV
2192
                        sendInvalidatedEvent = true;
×
UNCOV
2193
                        response.success = false;
×
UNCOV
2194
                        response.message = error.message;
×
2195
                    }
2196

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

2206
                if (v?.presentationHint?.lazy || v.isResolved) {
×
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) {
×
UNCOV
2209
                        updatedVariables = v.childVariables;
×
2210
                    } else {
UNCOV
2211
                        updatedVariables = [v];
×
2212
                    }
UNCOV
2213
                    v.isResolved = true;
×
2214
                } else {
UNCOV
2215
                    updatedVariables = v.childVariables;
×
2216
                }
2217

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

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

2232
            } else {
2233
                updatedVariables = v.childVariables;
4✔
2234
            }
2235

2236
            // Only send the updated variables if we are not going to trigger an invalidated event.
2237
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2238
            response.body = {
4✔
2239
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
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
            };
2243
        } catch (error) {
UNCOV
2244
            logger.error('Error during variablesRequest', error, { args });
×
UNCOV
2245
            response.success = false;
×
UNCOV
2246
            response.message = error?.message ?? 'Error during variablesRequest';
×
2247
        } finally {
2248
            logger.info('end', { response });
4✔
2249
        }
2250
        this.sendResponse(response);
4✔
2251
        if (sendInvalidatedEvent) {
4!
UNCOV
2252
            this.debounceSendInvalidatedEvent(null, frameId);
×
2253
        }
2254
    }
2255

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

2260

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

2266
        let start = args.start ?? 0;
4!
2267

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

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

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

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

2299
        return filteredUpdatedVariables;
4✔
2300
    }
2301

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

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

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

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

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

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

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

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

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

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

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

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

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

2427
                //is at debugger prompt
2428
            } else if (args.expression.trim()) {
14!
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

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

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

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

2465
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2466
                    if (args.context === 'repl') {
3!
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
2469
                        this.clearState();
3✔
2470
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2471
                    }
2472

2473
                    // If the adapter captured output (probably only telnet), log the results
2474
                    if (typeof commandResults.message === 'string') {
3✔
2475
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2476
                        if (args.context === 'repl') {
1!
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'));
1!
2480
                        }
2481
                    }
2482

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

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

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

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

UNCOV
2553
            results.evaluations[i] = returnVal;
×
2554
        }
2555

UNCOV
2556
        if (command) {
×
2557

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

UNCOV
2567
            command += bulkContainerStatement;
×
2568

UNCOV
2569
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2570

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

UNCOV
2577
        return results;
×
2578
    }
2579

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

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

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

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

2599
            let parentVariablePath = closestCompletionDetails.parentVariablePath;
20✔
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;
20✔
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.
2608
            const replaceRange = this.getCompletionReplaceRange(args);
20✔
2609
            // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
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');
20✔
2613
            const targetLine = lines[this.toDebuggerLine(args.line, 0)] ?? '';
20!
2614
            const precededByDot = targetLine[replaceRange.start - 1] === '.';
20✔
2615

2616
            // Get the completions if the variable path was valid
2617
            if (parentVariablePath) {
20!
2618

2619
                // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
2620
                if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
20✔
2621
                    supplyLocalScopeCompletions = true;
4✔
2622
                }
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);
20✔
2626

2627
                // provide completions for the parent variable if one was found
2628
                if (parentVariable) {
20✔
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 ||
19✔
2633
                        parentVariable.type === VariableType.List ||
2634
                        parentVariable.type === 'roXMLList' ||
2635
                        parentVariable.type === 'roByteArray';
2636

2637
                    const possibleFieldsAndMethods = isIntegerIndexed
19✔
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');
21!
2641

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

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

2661
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2662
                            label: v.name,
2663
                            type: completionType,
2664
                            //rank a variable's own members/locals above everything else
2665
                            sortText: `${CompletionSortTier.Member}${v.name}`
2666
                        };
2667
                        if (stringKeyClosing !== undefined) {
21✔
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`).
2671
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2672
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
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.
2676
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2677
                            completionItem.start = replaceRange.start - 1;
3✔
2678
                            completionItem.length = replaceRange.length + 1;
3✔
2679
                        }
2680
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2681
                    }
2682

2683
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2684
                    if (stringKeyClosing === undefined) {
19✔
2685
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2686
                        //assemble a list of all methods on the parent component
2687
                        const methods = [
15✔
2688
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2689
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2690
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2691
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2692
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2693
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
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) {
15✔
2698
                            completions.set(`method-${method.name}`, {
185✔
2699
                                label: method.name,
2700
                                type: 'method',
2701
                                detail: method.description ?? '',
555!
2702
                                sortText: `${CompletionSortTier.Method}${method.name}`
2703
                            });
2704
                        }
2705
                    }
2706

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

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

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

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

2747
            response.body = {
20✔
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'));
UNCOV
2753
            this.logger.error('Error during completionsRequest', error, { args });
×
2754
        }
2755
        this.sendResponse(response);
20✔
2756
    }
2757

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

2767
        const targetLine = lines[lineNumber] ?? '';
69!
2768

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

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.
2774
        if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
69✔
2775
            return undefined;
2✔
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.
2781
        let endColumn = column;
67✔
2782
        let isMemberAccess = false;
67✔
2783
        //when set (including ''), the user is completing a string key; the value is the text to append to
2784
        //close the access (ex: `"]`), empty when a closing bracket is already present
2785
        let stringKeyClosing: string;
2786

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

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

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

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

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

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

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

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

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

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

2871
    /**
2872
     * Compute the span of input text that a completion replaces: the run of identifier characters
2873
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2874
     * typing past the first character.
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
2878
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2879
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2880
     */
2881
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2882
        const lines = args.text.split('\n');
20✔
2883
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2884
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2885
        const targetLine = lines[lineNumber] ?? '';
20!
2886

2887
        const identifierChars = /[a-z0-9_]/i;
20✔
2888
        let wordStart = cursorOffset;
20✔
2889
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2890
            wordStart--;
7✔
2891
        }
2892
        return {
20✔
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.
2901
     */
2902
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2903
        let depth = 0;
67✔
2904
        for (let i = column - 1; i >= 0; i--) {
67✔
2905
            const char = line[i];
604✔
2906
            if (char === ')' || char === ']' || char === '}') {
604✔
2907
                depth++;
12✔
2908
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2909
                if (depth === 0) {
34✔
2910
                    return { index: i, char: char };
22✔
2911
                }
2912
                depth--;
12✔
2913
            }
2914
        }
2915
        return undefined;
45✔
2916
    }
2917

2918
    /**
2919
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
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> {
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] === '';
20✔
2927
        if (isLocalScope) {
20✔
2928
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2929
            if (!localsScope.isResolved) {
4!
2930
                try {
4✔
2931
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2932
                } catch (error) {
UNCOV
2933
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2934
                }
2935
            }
2936
        }
2937

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

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

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

2952
        let parentVariable: AugmentedVariable;
2953
        try {
5✔
2954
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2955
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2956
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
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 });
1✔
2960
            parentVariable = undefined;
1✔
2961
        }
2962

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

2967
    /**
2968
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2969
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
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
     */
2973
    private buildVariableExpression(segments: string[]): string {
2974
        return segments.reduce((expression, segment, index) => {
14✔
2975
            if (index === 0) {
27✔
2976
                return segment;
14✔
2977
            }
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.
2980
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
2981
                return `${expression}[${segment}]`;
2✔
2982
            }
2983
            if (/^[0-9]+$/.test(segment)) {
11✔
2984
                return `${expression}[${segment}]`;
3✔
2985
            }
2986
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
2987
                return `${expression}.${segment}`;
5✔
2988
            }
2989
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
2990
        }, '');
2991
    }
2992

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

3007
    /**
3008
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
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 {
3013
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
3014
        if (path.length === 1 && path[0] === '') {
20✔
3015
            return localsContainer;
4✔
3016
        }
3017
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
3018
    }
3019

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

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

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

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

3068
    /**
3069
     * Called when the host stops debugging
3070
     * @param response
3071
     * @param args
3072
     */
3073
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
3074
        //return to the home screen — best effort. The device may already be powered off or unreachable
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,
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) {
2!
3081
            try {
2✔
3082
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
3083
            } catch (e) {
3084
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
3085
            }
3086
        }
3087
        this.sendResponse(response);
2✔
3088
        await this.shutdown();
2✔
3089
    }
3090

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

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

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

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

UNCOV
3123
            try {
×
UNCOV
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

3131
                const state = appStateResult.state;
×
3132

UNCOV
3133
                if (state === AppState.active || state === AppState.background) {
×
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.
UNCOV
3138
                    await rokuECP.exitApp({
×
3139
                        host: this.launchConfiguration.host,
3140
                        remotePort: this.launchConfiguration.remotePort,
3141
                        appId: 'dev',
3142
                        requestOptions: { timeout: 300 }
3143
                    });
3144
                } else if (state === AppState.inactive) {
×
3145
                    return;
×
3146
                }
3147
            } catch (e) {
UNCOV
3148
                this.logger.error('Error attempting to exit application', e);
×
3149
            }
3150

UNCOV
3151
            await util.sleep(200);
×
3152
        }
3153
    }
3154

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

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

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

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

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

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

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

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

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) {
1!
3207
            this.entryBreakpointWasHandled = true;
1✔
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
3209
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
UNCOV
3210
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
UNCOV
3211
                return this.rokuAdapter.continue();
×
3212
            }
3213
        }
3214

3215
        const event: StoppedEvent = new StoppedEvent(
1✔
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
3218
            activeThread?.threadId ?? 0,
6!
3219
            '' //exception text
3220
        );
3221
        // Socket debugger will always stop all threads and supports multi thread inspection.
3222
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
3223
        this.sendEvent(event);
1✔
3224
    }
3225

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

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

3232
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3233
        //look up the correct line numbers for each thread from the StackTrace
3234
        await Promise.all(
8✔
3235
            threads.map(async (thread) => {
3236
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3237
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3238
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
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) {
9✔
3242
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3243
                    thread.lineNumber = stackTraceLineNumber;
2✔
3244
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3245
                }
3246
            })
3247
        );
3248

3249
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3250
            for (const thread of threads) {
4✔
3251
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
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)) {
4✔
3255
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
UNCOV
3256
                        this.logger.error('Error showing popup message', { error });
×
3257
                    });
3258
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3259
                    break outer;
1✔
3260
                }
3261
            }
3262
        }
3263

3264
        //sync breakpoints
3265
        await this.rokuAdapter?.syncBreakpoints();
8!
3266

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

3269
        this.clearState();
8✔
3270
        return threads;
8✔
3271
    }
3272

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

3276
        if (result) {
25!
3277
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3278
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3279
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
UNCOV
3280
                    try {
×
3281
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
UNCOV
3282
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
UNCOV
3283
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3284
                        this.mergeEvaluateContainers(result, newResult);
×
3285
                    } catch (error) {
UNCOV
3286
                        logger.error('Error getting variables', error);
×
UNCOV
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) {
16✔
3300
                    let value = `${result.value ?? result.type}`;
5!
3301
                    let indexedVariables = result.indexedVariables;
5✔
3302
                    let namedVariables = result.namedVariables;
5✔
3303

3304
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
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
UNCOV
3307
                        indexedVariables = undefined;
×
UNCOV
3308
                        namedVariables = undefined;
×
3309
                    }
3310

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

3322
                    let value: string;
3323
                    if (result.type === VariableType.Invalid) {
11!
3324
                        value = result.value ?? 'Invalid';
×
3325
                    } else if (result.type === VariableType.Uninitialized) {
11!
3326
                        value = 'Uninitialized';
×
3327
                    } else {
3328
                        value = `${result.value}`;
11✔
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);
11!
3333
                }
3334
                this.variables[refId] = v;
16✔
3335
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3336
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3337
                    v = new Variable(result.name, `${result.value}`);
7✔
3338
                } else if (result.highLevelType === 'array') {
2✔
3339
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3340
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3341
                    this.variables[refId] = v;
1✔
3342
                } else if (result.highLevelType === 'object') {
1!
3343
                    let refId: number;
3344
                    //handle collections
3345
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3346
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3347
                    }
3348
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3349
                    this.variables[refId] = v;
1✔
UNCOV
3350
                } else if (result.highLevelType === 'function') {
×
UNCOV
3351
                    v = new Variable(result.name, `${result.value}`);
×
3352
                } else {
3353
                    //all other cases, but mostly for HighLevelType.unknown
UNCOV
3354
                    v = new Variable(result.name, `${result.value}`);
×
3355
                }
3356
            }
3357

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

3367
            if (result.children && maxDepth > 0) {
25✔
3368
                if (!v.childVariables) {
7!
3369
                    v.childVariables = [];
7✔
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) => {
7✔
3374
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3375
                    return remapped;
10✔
3376
                });
3377
                if (this.enableDebugProtocol) {
7!
UNCOV
3378
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
UNCOV
3379
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
UNCOV
3380
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3381
                    });
3382

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

UNCOV
3387
                    try {
×
UNCOV
3388
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
UNCOV
3389
                        if (bulkEvaluations.bulkVarName) {
×
UNCOV
3390
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
UNCOV
3391
                            childrenToEvaluate.map((mappedChild, index) => {
×
UNCOV
3392
                                let newResult = newResults.children[index];
×
UNCOV
3393
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
UNCOV
3394
                                mappedChild.child.evaluateNow = false;
×
UNCOV
3395
                                return mappedChild;
×
3396
                            });
3397
                        }
3398
                    } catch (error) {
UNCOV
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) => {
7✔
3404
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3405
                }));
3406
            } else {
3407
                v.childVariables = [];
18✔
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) {
25!
UNCOV
3412
                if (isNaN(result.indexedVariables as number)) {
×
UNCOV
3413
                    v.value = v.type;
×
3414
                } else {
UNCOV
3415
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3416
                }
3417
            }
3418
        }
3419
        return v;
25✔
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) {
UNCOV
3427
        original.children = updated.children;
×
UNCOV
3428
        original.value = updated.value;
×
UNCOV
3429
        original.type = updated.type;
×
UNCOV
3430
        original.highLevelType = updated.highLevelType;
×
UNCOV
3431
        original.keyType = updated.keyType;
×
UNCOV
3432
        original.indexedVariables = updated.indexedVariables;
×
UNCOV
3433
        original.namedVariables = updated.namedVariables;
×
3434
    }
3435

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

3444
    private clearState() {
3445
        //erase all cached variables
3446
        this.variables = {};
12✔
3447
        this.completionParentVariableCache.clear();
12✔
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) {
75✔
3458
            return;
44✔
3459
        }
3460
        if (type === 'start') {
31✔
3461
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
10✔
3462
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
10✔
3463
        } else if (this.launchProgressId) {
21✔
3464
            if (type === 'update') {
18✔
3465
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3466
            } else {
3467
                const lastId = this.launchProgressId;
5✔
3468
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
5✔
3469
                setTimeout(() => {
5✔
3470
                    this.sendEvent(new ProgressEndEvent(lastId, message));
5✔
3471
                }, 1000); // add a slight delay before ending the progress to improve UX
3472
                this.launchProgressId = undefined;
5✔
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) {
3!
UNCOV
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) {
4!
3494
            this.entryBreakpointWasHandled = true;
4✔
3495
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3496
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
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);
3!
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);
3!
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') {
109✔
3532
            return this.convertClientLineToDebugger(clientLine);
2✔
3533
        }
3534
        return defaultDebuggerLine;
107✔
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') {
89!
3546
            return this.convertClientColumnToDebugger(clientLine);
89✔
3547
        }
UNCOV
3548
        return defaultDebuggerLine;
×
3549
    }
3550

3551
    private shutdownPromise: Promise<void> | undefined = undefined;
215✔
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> {
16✔
3558
        if (this.shutdownPromise === undefined) {
16!
3559
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
16✔
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(() => {
16✔
UNCOV
3563
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
UNCOV
3564
                this.forceExit();
×
3565
            }, this.shutdownForceExitTimeout);
3566
            forceExitTimer.unref?.();
16!
3567
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
16✔
3568
                clearTimeout(forceExitTimer);
16✔
3569
            });
3570
        } else {
UNCOV
3571
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3572
        }
3573
        return this.shutdownPromise;
16✔
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');
16✔
3579

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

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

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

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

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

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

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

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

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

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

3673
        try {
16✔
3674
            this.teardownProcessErrorHandlers();
16✔
3675
        } catch (e) {
UNCOV
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 · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc