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

rokucommunity / roku-debug / 28819206890

06 Jul 2026 07:56PM UTC coverage: 72.936% (+0.3%) from 72.68%
28819206890

Pull #323

github

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

3847 of 5504 branches covered (69.89%)

Branch coverage included in aggregate %.

92 of 119 new or added lines in 3 files covered. (77.31%)

712 existing lines in 3 files now uncovered.

6022 of 8027 relevant lines covered (75.02%)

47.39 hits per line

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

67.33
/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();
217✔
92

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

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

100
        this.fileManager = new FileManager();
217✔
101
        this.sourceMapManager = new SourceMapManager();
217✔
102
        this.locationManager = new LocationManager(this.sourceMapManager);
217✔
103
        this.breakpointManager = new BreakpointManager(this.sourceMapManager, this.locationManager);
217✔
104
        //send newly-verified breakpoints to vscode
105
        this.breakpointManager.on('breakpoints-verified', (data) => this.onDeviceBreakpointsChanged('changed', data));
217✔
106
        this.projectManager = new ProjectManager({
217✔
107
            breakpointManager: this.breakpointManager,
108
            locationManager: this.locationManager
109
        });
110
        this.fileLoggingManager = new FileLoggingManager();
217✔
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) {
684✔
148
            return;
4✔
149
        }
150
        super.sendEvent(event);
680✔
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;
217✔
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]`);
217✔
329

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

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

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
342

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

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

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

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

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

377
    private variables: Record<number, AugmentedVariable> = {};
217✔
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>();
217✔
385

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
387

388
    private perfettoManager: PerfettoManager;
389

390
    private rendezvousTracker: RendezvousTracker;
391

392
    public tempVarPrefix = '__rokudebug__';
217✔
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;
217✔
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[] = [];
217✔
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(
542✔
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!
UNCOV
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
            //if we have at least one installable complib, delete the dev app and any complibs
727
            //this is because manipulating complibs causes autolaunch (which often fails with compile errors)
728
            //so we want to do this BEFORE connecting to the adapter to avoid autolaunch and those compile errors
729
            if (this.launchConfiguration.componentLibraries?.some(x => x.install)) {
9!
UNCOV
730
                this.sendLaunchProgress('update', 'Removing existing dev app and component libraries');
×
UNCOV
731
                this.logger.log('Deleting any installed channel on the device to ensure a clean slate for component library installation');
×
UNCOV
732
                await rokuDeploy.deleteInstalledChannel({
×
733
                    host: this.launchConfiguration.host,
734
                    password: this.launchConfiguration.password
735
                });
UNCOV
736
                this.logger.log('Deleting any installed component libraries on the device to ensure a clean slate for component library installation');
×
UNCOV
737
                await this.deleteAllComponentLibraries();
×
738
            }
739

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

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

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

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

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

764
            await this.initializeProfiling();
9✔
765

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

875
            //references are fixed — seal the main project zip and the complib zips (then install + host)
876
            await Promise.all([
4✔
877
                this.zipMainProject(),
878
                this.zipAndHostComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
879
            ]);
880

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1112
    private publishTimeout = 60_000;
217✔
1113

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

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

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

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

1168
        await publishPromise;
2✔
1169

1170
        uploadingEnd();
2✔
1171

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

1191
    private pendingSendLogPromise = Promise.resolve();
217✔
1192

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

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

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

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

1232
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1233
                        }
1234
                    }
1235
                }
1236
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1237
                this.sendEvent(new LogOutputEvent(line));
264✔
1238
            }
1239
        });
1240
        return this.pendingSendLogPromise;
38✔
1241
    }
1242

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

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

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

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

1318
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1319

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

1326
            }
1327
        }
1328

1329
        return input;
30✔
1330
    }
1331

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1564
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
10✔
1565
        let resumeCompileErrorsPromise = Promise.resolve();
9✔
1566
        if (needToDeleteComplibs) {
9✔
1567
            //deleteAllComponentLibraries pauses compile-error reporting (to swallow deletion-induced errors) and
1568
            //leaves it paused. Now that deletion is done and we're about to put libraries back on the device, settle
1569
            //(draining any lingering deletion output) and resume reporting so real install errors are surfaced.
1570
            await this.deleteAllComponentLibraries();
8✔
1571
            //resume compile-error reporting (so any install errors are reported to the client). Don't wait for this to settle, we can hopefully make up
1572
            //all that time during the zipping process next
1573
            resumeCompileErrorsPromise = this.rokuAdapter?.resumeCompileErrors();
8!
1574
        }
1575

1576
        //seal every complib's zip in parallel — each targets distinct files
1577
        const packagePromises = this.projectManager.componentLibraryProjects.map(
9✔
1578
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
19✔
1579
        );
1580

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

1588
            if (compLibProject.install === true) {
19✔
1589
                //wait for this complib to finish being packaged
1590
                await packagePromises[i];
13✔
1591

1592
                if (componentLibraries[i].packageTask) {
13✔
1593
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1594
                }
1595

1596
                const options: RokuDeployOptions = {
13✔
1597
                    host: this.launchConfiguration.host,
1598
                    password: this.launchConfiguration.password,
1599
                    username: this.launchConfiguration.username || 'rokudev',
26✔
1600
                    logLevel: LogLevelPriority[this.logger.logLevel],
1601
                    failOnCompileError: true,
1602
                    outDir: compLibProject.outDir,
1603
                    outFile: compLibProject.outFile,
1604
                    appType: 'dcl',
1605
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
24✔
1606
                };
1607

1608
                if (componentLibraries[i].packagePath) {
13✔
1609
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
2✔
1610
                    options.outFile = path.basename(componentLibraries[i].packagePath);
2✔
1611
                }
1612

1613
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
13✔
1614
                try {
13✔
1615
                    // wait for compile error settling to finish and then resume compile error reporting so any install errors are reported to the client
1616
                    // most of the time this should already be done by the time the first library is ready to install, but if the complibs are small and zipping is fast,
1617
                    // this ensures we don't start installing before compile error reporting is resumed
1618
                    await resumeCompileErrorsPromise;
13✔
1619
                    await rokuDeploy.publish(options);
13✔
1620
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
12✔
1621
                } catch (error) {
1622
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
1623
                    //here means later libraries and the main app would reference a library that isn't on the device
1624
                    this.logger.error(`Error installing component library ${i} (${compLibProject.outFile})`, error);
1✔
1625
                    throw error;
1✔
1626
                }
1627
            }
1628
        }
1629

1630
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
8✔
NEW
1631
            util.log(message);
×
1632
        });
1633

1634
        //wait for all complib packaging to finish and the file hosting to start
1635
        await Promise.all([
8✔
1636
            ...packagePromises,
1637
            hostingPromise
1638
        ]);
1639
    }
1640

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

1660
        const getInstalledComplibs = async () => {
18✔
1661
            // eslint-disable-next-line @typescript-eslint/dot-notation
1662
            const packages = (await rokuDeploy['getInstalledPackages'](deviceOptions)) as Array<{ appType: 'channel' | 'dcl'; archiveFileName: string }>;
35✔
1663
            return packages.filter(x => x.appType === 'dcl');
35✔
1664
        };
1665

1666
        //The user's configured complibs, in REVERSE declaration order (dependents before their dependencies). A
1667
        //complib's position here is its delete priority; complibs the user didn't configure aren't in this list.
1668
        const deletePriority = this.projectManager.componentLibraryProjects
18✔
1669
            .map(complib => complib.outFile)
30✔
1670
            .reverse();
1671
        //sort key for a complib: its index in `deletePriority`, or Infinity (delete last) if it wasn't configured
1672
        const priorityOf = (fileName: string) => {
18✔
1673
            const index = deletePriority.indexOf(fileName);
24✔
1674
            return index === -1 ? Infinity : index;
24✔
1675
        };
1676

1677
        const maxAttempts = 5;
18✔
1678
        //how many times we've tried to delete each complib, and the ones we've given up on after maxAttempts
1679
        const attempts = new Map<string, number>();
18✔
1680
        const failed = new Set<string>();
18✔
1681

1682
        //deleting interdependent complibs makes the device emit transient compile errors (the main app/other complibs
1683
        //briefly reference a complib we just removed). Settle, then pause compile-error reporting so those don't reach
1684
        //the UI. We deliberately DON'T resume here - reporting stays paused until we put things back on the device
1685
        //(see the resume just before publishing), and the zipping work in between provides the settle time for free.
1686
        await this.rokuAdapter?.pauseCompileErrors();
18!
1687

1688
        while (true) {
16✔
1689
            //re-fetch the installed complibs after each iteration: deleting one complib can cascade-delete others, so never delete a stale entry
1690
            const installed = await getInstalledComplibs();
35✔
1691

1692
            const attemptCount = (complib: { archiveFileName: string }) => attempts.get(complib.archiveFileName) ?? 0;
35✔
1693

1694
            //of the complibs we haven't given up on, pick the next to delete: fewest attempts first (spreads retries
1695
            //evenly so a dependent gets deleted before we circle back to a complib that previously failed), and among
1696
            //equal attempts, follow the reverse-configured delete priority (dependents before dependencies).
1697
            const candidates = installed.filter(complib => !failed.has(complib.archiveFileName));
35✔
1698
            const next = candidates.sort((a, b) =>
35✔
1699
                attemptCount(a) - attemptCount(b) ||
15✔
1700
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
1701
            )[0];
1702

1703
            //nothing left to try — done if the device is clear, otherwise hard-fail with whatever remains
1704
            if (!next) {
35✔
1705
                if (installed.length > 0) {
16!
UNCOV
1706
                    throw new Error(`Failed to delete existing component libraries on device; ${installed.length} still installed: ${installed.map(x => x.archiveFileName).join(', ')}`);
×
1707
                }
1708
                return;
16✔
1709
            }
1710

1711
            const fileName = next.archiveFileName;
19✔
1712
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
19✔
1713
            try {
19✔
1714
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
19✔
1715
            } catch (error) {
1716
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
1717
                if (!this.isComponentLibraryDependencyCompileError(error)) {
3!
UNCOV
1718
                    throw error;
×
1719
                }
1720
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
1721
                //STILL have succeeded (the device reports both a compile error and 'Delete Succeeded'). If it actually
1722
                //deleted, treat it as done. Only if it did NOT succeed do we defer and retry it later.
1723
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
3!
UNCOV
1724
                    this.logger.trace(`Deleted '${fileName}' (device reported a compile error but the delete succeeded)`);
×
1725
                } else {
1726
                    //another not-yet-deleted complib still depends on this one. Give up on it once it's out of
1727
                    //attempts; otherwise leave it pending so we retry after its dependents are gone.
1728
                    if (attempts.get(fileName) >= maxAttempts) {
3!
UNCOV
1729
                        failed.add(fileName);
×
1730
                    }
1731
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
3✔
1732
                }
1733
            }
1734

1735
            //the Roku doesn't appreciate back-to-back deletes; give it a moment between requests
1736
            await util.sleep(10);
19✔
1737
        }
1738
    }
1739

1740
    /**
1741
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
1742
     * installed complib still references the one we tried to delete)?
1743
     */
1744
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1745
        const message = `${error?.message ?? ''}`;
3!
1746
        return /compile error|compilation failed/i.test(message);
3✔
1747
    }
1748

1749
    /**
1750
     * Did the component-library delete actually succeed, even though the device also reported a compile error?
1751
     * When we delete a complib that a dependent still references, the device reports BOTH a compile error AND a
1752
     * `Delete Succeeded` message - meaning the complib really was removed. roku-deploy attaches the parsed device
1753
     * messages (`{ errors, infos, successes }`) to the thrown error as `.results`; we look for the success there.
1754
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
1755
     */
1756
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
1757
        const successes: string[] = error?.results?.successes ?? [];
3!
1758
        return successes.some(message => /delete succeeded/i.test(message));
3✔
1759
    }
1760

1761
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
UNCOV
1762
        this.logger.log('sourceRequest');
×
UNCOV
1763
        let old = this.sendResponse;
×
UNCOV
1764
        this.sendResponse = function sendResponse(...args) {
×
UNCOV
1765
            old.apply(this, args);
×
UNCOV
1766
            this.sendResponse = old;
×
1767
        };
UNCOV
1768
        super.sourceRequest(response, args);
×
1769
    }
1770

1771
    /**
1772
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1773
     */
1774
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1775
        this.logger.log('setBreakpointsRequest', args);
6✔
1776
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6✔
1777
        //sort the breakpoints
1778
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6✔
1779

1780
        response.body = {
6✔
1781
            breakpoints: sortedAndFilteredBreakpoints
1782
        };
1783
        this.sendResponse(response);
6✔
1784

1785
        //ensure we've staged all the files
1786
        await this.stagingDefered.promise;
6✔
1787

1788
        await this.rokuAdapter?.syncBreakpoints();
6!
1789
    }
1790

1791
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
UNCOV
1792
        this.logger.log('exceptionInfoRequest');
×
1793
    }
1794

1795
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1796
        this.logger.log('threadsRequest');
4✔
1797

1798
        let threads = [];
4✔
1799

1800
        //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
1801
        if (this.compileError) {
4!
UNCOV
1802
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1803
        } else {
1804
            //wait for the roku adapter to load
1805
            await this.getRokuAdapter();
4✔
1806

1807
            //only send the threads request if we are at the debugger prompt
1808
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1809
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1810

1811
                for (let thread of rokuThreads) {
3✔
1812
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1813
                    threads.push(
4✔
1814
                        new Thread(thread.threadId, threadName)
1815
                    );
1816
                }
1817

1818
                if (threads.length === 0) {
3!
UNCOV
1819
                    threads = [{
×
1820
                        id: 1001,
1821
                        name: 'unable to retrieve threads: not stopped',
1822
                        isFake: true
1823
                    }];
1824
                }
1825

1826
            } else {
1827
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1828
            }
1829

1830
        }
1831

1832
        response.body = {
4✔
1833
            threads: threads
1834
        };
1835

1836
        this.sendResponse(response);
4✔
1837
    }
1838

1839
    /**
1840
     * Get the thread name to display in the UI based on the thread info we have.
1841
     * This is what displays in the `call stack` region in vscode
1842
     * @param thread
1843
     * @returns
1844
     */
1845
    private getThreadName(thread: AdapterThread) {
1846
        let threadName = '';
24✔
1847
        if (thread.type || thread.name || thread.osThreadId) {
24✔
1848
            //build the name from only the parts that are present, so missing values don't leak into the name
1849
            const parts: string[] = [];
13✔
1850
            if (thread.type) {
13✔
1851
                parts.push(`[${thread.type}]`);
10✔
1852
            }
1853
            if (thread.name) {
13✔
1854
                parts.push(thread.name);
9✔
1855
            }
1856
            if (thread.osThreadId) {
13✔
1857
                parts.push(thread.osThreadId);
9✔
1858
            }
1859
            threadName = parts.join(' ');
13✔
1860
        }
1861
        //remove any extraneous whitespace to deal with missing values
1862
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1863

1864
        if (threadName === '') {
24✔
1865
            threadName = `Thread ${thread.threadId}`;
11✔
1866
        }
1867

1868
        if (thread.isDetached) {
24✔
1869
            threadName += ' [detached]';
4✔
1870
        }
1871

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

1875
        return threadName;
24✔
1876
    }
1877

1878
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1879
        try {
3✔
1880
            this.logger.log('stackTraceRequest');
3✔
1881
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1882

1883
            //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
1884
            if (this.compileError) {
3!
UNCOV
1885
                frames.push(new StackFrame(
×
1886
                    0,
1887
                    'Compile Error',
1888
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1889
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1890
                    this.toClientLine(this.compileError.range.start.line),
1891
                    this.toClientColumn(this.compileError.range.start.character)
1892
                ));
1893
            } else if (args.threadId === 1001) {
3!
UNCOV
1894
                frames.push(new StackFrame(
×
1895
                    0,
1896
                    'ERROR: threads would not stop',
1897
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1898
                    this.toClientLine(0),
1899
                    this.toClientColumn(0)
1900
                ));
UNCOV
1901
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
UNCOV
1902
                    this.logger.error('Error showing popup message', { error });
×
1903
                });
1904
            } else {
1905
                //ensure the rokuAdapter is loaded
1906
                await this.getRokuAdapter();
3✔
1907

1908
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1909
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1910
                    if (stackTrace.length === 0) {
3✔
1911
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1912
                        // the thread without letting the user navigate to a source location
1913
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1914
                        frame.presentationHint = 'label';
2✔
1915
                        frames.push(frame);
2✔
1916
                    } else {
1917
                        for (let debugFrame of stackTrace) {
1✔
1918
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
1919

1920
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1921
                            //load the contents of the file and get the correct casing for the function identifier
1922
                            try {
3✔
1923
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1924
                                if (functionName) {
3!
1925

1926
                                    //search for original function name if this is an anonymous function.
1927
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1928
                                    if (functionName.startsWith('$')) {
3!
1929
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1930
                                            sourceLocation.filePath,
1931
                                            sourceLocation.lineNumber - 1,
1932
                                            functionName
1933
                                        );
1934
                                    }
1935
                                    debugFrame.functionIdentifier = functionName;
3✔
1936
                                }
1937
                            } catch (error) {
1938
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1939
                            }
1940
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3!
1941

1942
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1943
                                debugFrame.frameId,
1944
                                `${debugFrame.functionIdentifier}`,
1945
                                new Source(path.basename(filePath), filePath),
1946
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1947
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18!
1948
                                this.toClientColumn(0)
1949
                            );
1950
                            if (!sourceLocation) {
3!
UNCOV
1951
                                frame.presentationHint = 'subtle';
×
1952
                            }
1953
                            frames.push(frame);
3✔
1954
                        }
1955
                    }
1956
                } else {
UNCOV
1957
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1958
                }
1959
            }
1960
            response.body = {
3✔
1961
                stackFrames: frames,
1962
                totalFrames: frames.length
1963
            };
1964
            this.sendResponse(response);
3✔
1965
        } catch (error) {
UNCOV
1966
            this.logger.error('Error getting stacktrace', { error, args });
×
1967
        }
1968
    }
1969

1970
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1971
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1✔
1972
        logger.info('begin', { args });
1✔
1973
        try {
1✔
1974
            const scopes = new Array<DebugProtocol.Scope>();
1✔
1975

1976
            // create the locals scope
1977
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1978

1979
            let localScope: DebugProtocol.Scope = {
1✔
1980
                name: 'Local',
1981
                variablesReference: v.variablesReference,
1982
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1983
                expensive: this.launchConfiguration.deferScopeLoading,
1984
                presentationHint: 'locals'
1985
            };
1986

1987
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
UNCOV
1988
            if (frame) {
×
UNCOV
1989
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
1990

1991
                if (scopeRange) {
×
1992
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
1993
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
UNCOV
1994
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
UNCOV
1995
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1996
                }
1997
            }
1998

UNCOV
1999
            scopes.push(localScope);
×
2000

2001
            // create the registry scope
2002
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
UNCOV
2003
            scopes.push(<DebugProtocol.Scope>{
×
2004
                name: 'Registry',
2005
                variablesReference: registryRefId,
2006
                expensive: true
2007
            });
2008

UNCOV
2009
            this.variables[registryRefId] = {
×
2010
                variablesReference: registryRefId,
2011
                name: 'Registry',
2012
                value: '',
2013
                type: '$$Registry',
2014
                isScope: true,
2015
                childVariables: []
2016
            };
2017

UNCOV
2018
            response.body = {
×
2019
                scopes: scopes
2020
            };
2021
            logger.debug('send response', { response });
×
UNCOV
2022
            this.sendResponse(response);
×
UNCOV
2023
            logger.info('end');
×
2024
        } catch (error) {
2025
            logger.error('Error getting scopes', { error, args });
1✔
2026
        }
2027
    }
2028

2029
    /**
2030
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
2031
     * The child variables are filled in lazily by `populateScopeVariables`.
2032
     */
2033
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
2034
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
2035
        if (!this.variables[refId]) {
5✔
2036
            this.variables[refId] = {
1✔
2037
                variablesReference: refId,
2038
                name: 'Locals',
2039
                value: '',
2040
                type: '$$Locals',
2041
                frameId: frameId,
2042
                isScope: true,
2043
                childVariables: []
2044
            };
2045
        }
2046
        return this.variables[refId];
5✔
2047
    }
2048

2049
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
2050
        //if we have a compile error, we should shut down
UNCOV
2051
        if (this.compileError) {
×
2052
            this.sendResponse(response);
×
UNCOV
2053
            await this.shutdown();
×
UNCOV
2054
            return;
×
2055
        }
2056

2057
        this.logger.log('continueRequest');
×
2058
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
2059
        this.clearState();
×
2060

2061
        // The debug session ends after the next line. Do not put new work after this line.
2062
        await this.rokuAdapter.continue();
×
UNCOV
2063
        this.sendResponse(response);
×
2064
    }
2065

2066
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
2067
        this.logger.log('pauseRequest');
×
2068

2069
        //if we have a compile error, we should shut down
UNCOV
2070
        if (this.compileError) {
×
2071
            this.sendResponse(response);
×
2072
            await this.shutdown();
×
2073
            return;
×
2074
        }
2075

UNCOV
2076
        await this.rokuAdapter.pause();
×
UNCOV
2077
        this.sendResponse(response);
×
2078
    }
2079

2080
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
UNCOV
2081
        this.logger.log('reverseContinueRequest');
×
UNCOV
2082
        this.sendResponse(response);
×
2083
    }
2084

2085
    /**
2086
     * Clicked the "Step Over" button
2087
     * @param response
2088
     * @param args
2089
     */
2090
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
2091
        this.logger.log('[nextRequest] begin');
×
2092

2093
        //if we have a compile error, we should shut down
UNCOV
2094
        if (this.compileError) {
×
UNCOV
2095
            this.sendResponse(response);
×
UNCOV
2096
            await this.shutdown();
×
UNCOV
2097
            return;
×
2098
        }
2099

2100
        await this.setTransientsToInvalid(); // call before clearState
×
2101
        this.clearState();
×
2102

2103
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
2104
        try {
×
UNCOV
2105
            await this.rokuAdapter.stepOver(args.threadId);
×
UNCOV
2106
            this.logger.info('[nextRequest] end');
×
2107
        } catch (error) {
UNCOV
2108
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
2109
        }
UNCOV
2110
        this.sendResponse(response);
×
2111
    }
2112

2113
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
UNCOV
2114
        this.logger.log('[stepInRequest]');
×
2115

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

2123
        await this.setTransientsToInvalid(); // call before clearState
×
2124
        this.clearState();
×
2125
        // The debug session ends after the next line. Do not put new work after this line.
2126
        await this.rokuAdapter.stepInto(args.threadId);
×
2127
        this.sendResponse(response);
×
2128
        this.logger.info('[stepInRequest] end');
×
2129
    }
2130

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

2134
        //if we have a compile error, we should shut down
2135
        if (this.compileError) {
×
2136
            this.sendResponse(response);
×
2137
            await this.shutdown();
×
UNCOV
2138
            return;
×
2139
        }
2140

2141
        await this.setTransientsToInvalid(); // call before clearState
×
UNCOV
2142
        this.clearState();
×
2143

2144
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
2145
        await this.rokuAdapter.stepOut(args.threadId);
×
2146
        this.sendResponse(response);
×
UNCOV
2147
        this.logger.info('[stepOutRequest] end');
×
2148
    }
2149

2150
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
UNCOV
2151
        this.logger.log('[stepBackRequest] begin');
×
UNCOV
2152
        this.sendResponse(response);
×
UNCOV
2153
        this.logger.info('[stepBackRequest] end');
×
2154
    }
2155

2156
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
2157
        const logger = this.logger.createLogger('[variablesRequest]');
4✔
2158
        let sendInvalidatedEvent = false;
4✔
2159
        let frameId: number = null;
4✔
2160
        try {
4✔
2161
            logger.log('begin', { args });
4✔
2162

2163
            //ensure the rokuAdapter is loaded
2164
            await this.getRokuAdapter();
4✔
2165

2166
            let updatedVariables: AugmentedVariable[] = [];
4✔
2167
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
2168
            await this.evaluateRequestPromise;
4✔
2169
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
UNCOV
2170
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
UNCOV
2171
                response.success = false;
×
UNCOV
2172
                response.message = 'Debug session is not paused';
×
UNCOV
2173
                return this.sendResponse(response);
×
2174
            }
2175

2176
            //find the variable with this reference
2177
            let v = this.variables[args.variablesReference];
4✔
2178
            if (!v) {
4!
2179
                response.success = false;
×
2180
                response.message = `Variable reference has expired`;
×
2181
                return this.sendResponse(response);
×
2182
            }
2183
            logger.log('variable', v);
4✔
2184

2185
            // Populate scope level values if needed
2186
            if (v.isScope) {
4✔
2187
                await this.populateScopeVariables(v, args);
2✔
2188
            }
2189

2190
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
2191
            if (v.childVariables.length === 0 || v.isResolved) {
4!
2192
                let tempVar: AugmentedVariable;
UNCOV
2193
                if (!v.isResolved) {
×
2194
                    // Evaluate the variable
UNCOV
2195
                    try {
×
UNCOV
2196
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
UNCOV
2197
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
2198
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
UNCOV
2199
                        tempVar.frameId = v.frameId;
×
2200
                        // Determine if the variable has changed
UNCOV
2201
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
2202
                    } catch (error) {
UNCOV
2203
                        logger.error('Error getting variables', error);
×
UNCOV
2204
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
UNCOV
2205
                        tempVar.type = '';
×
2206
                        tempVar.childVariables = [];
×
2207
                        sendInvalidatedEvent = true;
×
UNCOV
2208
                        response.success = false;
×
2209
                        response.message = error.message;
×
2210
                    }
2211

2212
                    // Merge the resulting updates together
UNCOV
2213
                    v.childVariables = tempVar.childVariables;
×
UNCOV
2214
                    v.value = tempVar.value;
×
UNCOV
2215
                    v.type = tempVar.type;
×
UNCOV
2216
                    v.indexedVariables = tempVar.indexedVariables;
×
UNCOV
2217
                    v.namedVariables = tempVar.namedVariables;
×
2218
                }
UNCOV
2219
                frameId = v.frameId;
×
2220

UNCOV
2221
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2222
                    // If this was a lazy variable we need to respond with the updated variable and not the children
UNCOV
2223
                    if (v.isResolved && v.childVariables.length > 0) {
×
UNCOV
2224
                        updatedVariables = v.childVariables;
×
2225
                    } else {
UNCOV
2226
                        updatedVariables = [v];
×
2227
                    }
UNCOV
2228
                    v.isResolved = true;
×
2229
                } else {
UNCOV
2230
                    updatedVariables = v.childVariables;
×
2231
                }
2232

2233
                // If the variable has no children, set the reference to 0
2234
                // so it does not look expandable in the Ui
UNCOV
2235
                if (v.childVariables.length === 0) {
×
UNCOV
2236
                    v.variablesReference = 0;
×
2237
                }
2238

2239
                // If the variable was resolve in the past we may not have fetched a new temp var
UNCOV
2240
                tempVar ??= v;
×
UNCOV
2241
                if (v?.presentationHint) {
×
UNCOV
2242
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2243
                } else {
UNCOV
2244
                    v.presentationHint = tempVar.presentationHint;
×
2245
                }
2246

2247
            } else {
2248
                updatedVariables = v.childVariables;
4✔
2249
            }
2250

2251
            // Only send the updated variables if we are not going to trigger an invalidated event.
2252
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2253
            response.body = {
4✔
2254
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2255
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2256
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2257
            };
2258
        } catch (error) {
UNCOV
2259
            logger.error('Error during variablesRequest', error, { args });
×
2260
            response.success = false;
×
2261
            response.message = error?.message ?? 'Error during variablesRequest';
×
2262
        } finally {
2263
            logger.info('end', { response });
4✔
2264
        }
2265
        this.sendResponse(response);
4✔
2266
        if (sendInvalidatedEvent) {
4!
UNCOV
2267
            this.debounceSendInvalidatedEvent(null, frameId);
×
2268
        }
2269
    }
2270

2271
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
217✔
UNCOV
2272
        this.sendInvalidatedEvent(threadId, frameId);
×
2273
    }, 50);
2274

2275

2276
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2277
        if (!updatedVariables || !v) {
4!
2278
            return [];
×
2279
        }
2280

2281
        let start = args.start ?? 0;
4!
2282

2283
        //if the variable is an array, send only the requested range
2284
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4!
2285
            //only send the variable range requested by the debugger
UNCOV
2286
            if (!args.count) {
×
UNCOV
2287
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2288
            } else {
UNCOV
2289
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2290
            }
2291
        }
2292

2293
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4!
2294
            // We currently do not support named variable paging so we always send all named variables
2295
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2296
        }
2297

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

2301
        if (this.launchConfiguration.showHiddenVariables !== true) {
4✔
2302
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2✔
2303
                //A transient variable that we show when there is a value
2304
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4!
UNCOV
2305
                    return true;
×
2306
                } else if (util.isTransientVariable(child.name)) {
4!
2307
                    return false;
×
2308
                } else {
2309
                    return true;
4✔
2310
                }
2311
            });
2312
        }
2313

2314
        return filteredUpdatedVariables;
4✔
2315
    }
2316

2317
    /**
2318
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2319
     * @param v scope variable to populate
2320
     * @param args
2321
     */
2322
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2323
        if (v.childVariables.length > 0) {
4✔
2324
            // Already populated
2325
            return;
3✔
2326
        }
2327

2328
        let tempVar: AugmentedVariable;
2329
        try {
1✔
2330
            if (v.type === '$$Locals') {
1!
2331
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1!
2332
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1✔
2333
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1✔
2334
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2335
                    // NOTE: Legacy telnet support
2336
                    let variables: AugmentedVariable[] = [];
×
2337
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2338

2339
                    // Fetch each variable individually
UNCOV
2340
                    for (const varName of varNames) {
×
UNCOV
2341
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
UNCOV
2342
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
UNCOV
2343
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
UNCOV
2344
                        variables.push(tempLocalsVar);
×
2345
                    }
UNCOV
2346
                    tempVar = {
×
2347
                        ...v,
2348
                        childVariables: variables,
2349
                        namedVariables: variables.length,
2350
                        indexedVariables: 0
2351
                    };
2352
                }
2353

2354
                // Merge the resulting updates together onto the original variable
2355
                v.childVariables = tempVar.childVariables;
1✔
2356
                v.namedVariables = tempVar.namedVariables;
1✔
2357
                v.indexedVariables = tempVar.indexedVariables;
1✔
UNCOV
2358
            } else if (v.type === '$$Registry') {
×
2359
                // This is a special scope variable used to load registry data via an ECP call
2360
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
UNCOV
2361
                await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2362
            }
2363
        } catch (error) {
UNCOV
2364
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
UNCOV
2365
            tempVar = {
×
2366
                name: '',
2367
                value: `❌ Error: ${error.message}`,
2368
                variablesReference: 0,
2369
                childVariables: []
2370
            };
UNCOV
2371
            v.childVariables = [tempVar];
×
UNCOV
2372
            v.namedVariables = 1;
×
UNCOV
2373
            v.indexedVariables = 0;
×
2374
        }
2375

2376
        // Mark the scope as resolved so we don't re-fetch the variables
2377
        v.isResolved = true;
1✔
2378

2379
        // If the scope has no children, add a single child to indicate there are no values
2380
        if (v.childVariables.length === 0) {
1!
UNCOV
2381
            tempVar = {
×
2382
                name: '',
2383
                value: `No values for scope '${v.name}'`,
2384
                variablesReference: 0,
2385
                childVariables: []
2386
            };
UNCOV
2387
            v.childVariables = [tempVar];
×
UNCOV
2388
            v.namedVariables = 1;
×
UNCOV
2389
            v.indexedVariables = 0;
×
2390
        }
2391
    }
2392

2393
    private evaluateRequestPromise = Promise.resolve();
217✔
2394
    private evaluateVarIndexByFrameId = new Map<number, number>();
217✔
2395

2396
    private getNextVarIndex(frameId: number): number {
2397
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6✔
2398
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5✔
2399
        }
2400
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6✔
2401
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6✔
2402
        return value;
6✔
2403
    }
2404

2405
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2406
        //ensure the rokuAdapter is loaded
2407
        await this.getRokuAdapter();
15✔
2408

2409
        let deferred = defer<void>();
15✔
2410
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15!
UNCOV
2411
            this.clearState();
×
UNCOV
2412
            this.rokuAdapter.clearCache();
×
UNCOV
2413
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
UNCOV
2414
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
UNCOV
2415
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
UNCOV
2416
            this.sendResponse(response);
×
UNCOV
2417
            return deferred.promise;
×
2418
        }
2419

2420
        try {
15✔
2421
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2422
                return deferred.promise;
15✔
2423
            });
2424

2425
            //fix vscode hover bug that excludes closing quotemark sometimes.
2426
            if (args.context === 'hover') {
15✔
2427
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2428
            }
2429

2430
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15✔
2431
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1✔
2432
                if (args.context === 'repl') {
1!
2433
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1✔
2434
                    response.body = {
1✔
2435
                        result: 'invalid',
2436
                        variablesReference: 0
2437
                    };
2438
                } else {
UNCOV
2439
                    throw new Error(message);
×
2440
                }
2441

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

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

2449
                //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`
2450
                if (variablePath) {
14✔
2451
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2452
                    let v: AugmentedVariable;
2453
                    //if we already looked this item up, return it
2454
                    if (this.variables[refId]) {
11✔
2455
                        v = this.variables[refId];
1✔
2456
                    } else {
2457
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2458
                        if (!result) {
10!
UNCOV
2459
                            throw new Error('Error: unable to evaluate expression');
×
2460
                        }
2461

2462
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10✔
2463
                        //TODO - testing something, remove later
2464
                        // eslint-disable-next-line camelcase
2465
                        v.request_seq = response.request_seq;
10✔
2466
                        v.frameId = evalArgs.frameId;
10✔
2467
                    }
2468
                    response.body = {
11✔
2469
                        result: v.value,
2470
                        type: v.type,
2471
                        variablesReference: v.variablesReference,
2472
                        namedVariables: v.namedVariables || 0,
21✔
2473
                        indexedVariables: v.indexedVariables || 0
21✔
2474
                    };
2475

2476
                    //run an `evaluate` call
2477
                } else {
2478
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2479

2480
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2481
                    if (args.context === 'repl') {
3!
2482
                        // Clear variable cache since this action could have side-effects
2483
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2484
                        this.clearState();
3✔
2485
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2486
                    }
2487

2488
                    // If the adapter captured output (probably only telnet), log the results
2489
                    if (typeof commandResults.message === 'string') {
3✔
2490
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2491
                        if (args.context === 'repl') {
1!
2492
                            // If the command was a repl command, send the output to the debug console for the developer as well
2493
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2494
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1!
2495
                        }
2496
                    }
2497

2498
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3✔
2499
                        response.body = {
2✔
2500
                            result: 'invalid',
2501
                            variablesReference: 0
2502
                        };
2503
                    } else {
2504
                        response.body = {
1✔
2505
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1!
2506
                            variablesReference: 0
2507
                        };
2508
                    }
2509
                }
2510
            }
2511
        } catch (error) {
2512
            this.logger.error('Error during variables request', error);
×
UNCOV
2513
            response.success = false;
×
UNCOV
2514
            response.message = error?.message ?? error;
×
2515
        }
2516
        try {
15✔
2517
            this.sendResponse(response);
15✔
2518
        } catch { }
2519
        deferred.resolve();
15✔
2520
    }
2521

2522
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2523
        let returnVal = { evalArgs: args, variablePath };
19✔
2524
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19✔
2525
            let varIndex = this.getNextVarIndex(args.frameId);
6✔
2526
            let arrayVarName = this.tempVarPrefix + 'eval';
6✔
2527
            let command = '';
6✔
2528
            if (varIndex === 0) {
6✔
2529
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5✔
2530
            }
2531
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6✔
2532
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6✔
2533
            command += statement;
6✔
2534
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6✔
2535
            if (commandResults.type === 'error') {
6!
UNCOV
2536
                throw new Error(commandResults.message);
×
2537
            }
2538
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6✔
2539
        }
2540
        return returnVal;
19✔
2541
    }
2542

2543
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
UNCOV
2544
        let results = {
×
2545
            evaluations: [],
2546
            bulkVarName: ''
2547
        };
UNCOV
2548
        let storedVariables = [];
×
UNCOV
2549
        let command = '';
×
UNCOV
2550
        for (let i = 0; i < argsArray.length; i++) {
×
UNCOV
2551
            let args = argsArray[i];
×
UNCOV
2552
            let variablePath = variablePathArray[i];
×
UNCOV
2553
            let returnVal = { evalArgs: args, variablePath };
×
UNCOV
2554
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
UNCOV
2555
                let varIndex = this.getNextVarIndex(frameId);
×
UNCOV
2556
                let arrayVarName = this.tempVarPrefix + 'eval';
×
UNCOV
2557
                if (varIndex === 0) {
×
UNCOV
2558
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2559
                }
UNCOV
2560
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
UNCOV
2561
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
UNCOV
2562
                command += statement;
×
2563

UNCOV
2564
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
UNCOV
2565
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2566
            }
2567

UNCOV
2568
            results.evaluations[i] = returnVal;
×
2569
        }
2570

UNCOV
2571
        if (command) {
×
2572

2573
            // create a bulk container for the command results
UNCOV
2574
            let varIndex = this.getNextVarIndex(frameId);
×
UNCOV
2575
            let arrayVarName = this.tempVarPrefix + 'eval';
×
UNCOV
2576
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
UNCOV
2577
            for (let storedVariable of storedVariables) {
×
UNCOV
2578
                bulkContainerStatement += `${storedVariable},\n`;
×
2579
            }
UNCOV
2580
            bulkContainerStatement += `]`;
×
2581

UNCOV
2582
            command += bulkContainerStatement;
×
2583

UNCOV
2584
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2585

UNCOV
2586
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
UNCOV
2587
            if (commandResults.type === 'error') {
×
UNCOV
2588
                throw new Error(commandResults.message);
×
2589
            }
2590
        }
2591

UNCOV
2592
        return results;
×
2593
    }
2594

2595
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2596
        this.logger.log('completionsRequest', args, request);
20✔
2597
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2598
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2599

2600
        try {
20✔
2601
            let supplyLocalScopeCompletions = false;
20✔
2602

2603
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2604

2605
            if (!closestCompletionDetails) {
20!
2606
                // If the cursor is not at the end of the line, then we should not supply completions at this time
UNCOV
2607
                response.body = {
×
2608
                    targets: []
2609
                };
UNCOV
2610
                return this.sendResponse(response);
×
2611
            }
2612
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20✔
2613

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

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

2631
            // Get the completions if the variable path was valid
2632
            if (parentVariablePath) {
20!
2633

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

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

2642
                // provide completions for the parent variable if one was found
2643
                if (parentVariable) {
20✔
2644
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2645
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2646
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2647
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19✔
2648
                        parentVariable.type === VariableType.List ||
2649
                        parentVariable.type === 'roXMLList' ||
2650
                        parentVariable.type === 'roByteArray';
2651

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

2657
                    for (let v of possibleFieldsAndMethods) {
19✔
2658
                        // Default completion type should be variable
2659
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21✔
2660
                        if (!supplyLocalScopeCompletions) {
21✔
2661
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2662
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17!
2663
                                completionType = 'field';
17✔
2664
                            }
2665

2666
                            switch (v.type) {
17!
2667
                                case VariableType.Function:
2668
                                case VariableType.Subroutine:
UNCOV
2669
                                    completionType = 'method';
×
UNCOV
2670
                                    break;
×
2671
                                default:
2672
                                    break;
17✔
2673
                            }
2674
                        }
2675

2676
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2677
                            label: v.name,
2678
                            type: completionType,
2679
                            //rank a variable's own members/locals above everything else
2680
                            sortText: `${CompletionSortTier.Member}${v.name}`
2681
                        };
2682
                        if (stringKeyClosing !== undefined) {
21✔
2683
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2684
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2685
                            // (ex: a key of `a"b` is inserted as `a""b`).
2686
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2687
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
2688
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2689
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2690
                            // the key is escaped as `""` so the inserted string literal stays valid.
2691
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2692
                            completionItem.start = replaceRange.start - 1;
3✔
2693
                            completionItem.length = replaceRange.length + 1;
3✔
2694
                        }
2695
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2696
                    }
2697

2698
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2699
                    if (stringKeyClosing === undefined) {
19✔
2700
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2701
                        //assemble a list of all methods on the parent component
2702
                        const methods = [
15✔
2703
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2704
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2705
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2706
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2707
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2708
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
2709
                        ].flat();
2710

2711
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2712
                        for (const method of methods) {
15✔
2713
                            completions.set(`method-${method.name}`, {
185✔
2714
                                label: method.name,
2715
                                type: 'method',
2716
                                detail: method.description ?? '',
555!
2717
                                sortText: `${CompletionSortTier.Method}${method.name}`
2718
                            });
2719
                        }
2720
                    }
2721

2722
                    // Add the global functions to the completions results
2723
                    if (supplyLocalScopeCompletions) {
19✔
2724
                        for (let globalCallable of globalCallables) {
4✔
2725
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308✔
2726
                                label: globalCallable.name,
2727
                                type: 'function',
2728
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848!
2729
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2730
                            });
2731
                        }
2732

2733
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2734

2735
                        try {
4✔
2736
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4✔
2737
                            for (let scopeFunction of scopeFunctions) {
4✔
2738
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1!
2739
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2740
                                        label: scopeFunction.name,
2741
                                        type: scopeFunction.completionItemKind,
2742
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2743
                                    });
2744
                                }
2745
                            }
2746
                        } catch (e) {
UNCOV
2747
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2748
                        }
2749
                    }
2750
                }
2751
            }
2752

2753
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2754
            // rewrites above use an extended range that also consumes the preceding `.`).
2755
            for (const target of completions.values()) {
20✔
2756
                if (target.start === undefined) {
499✔
2757
                    target.start = replaceRange.start;
496✔
2758
                    target.length = replaceRange.length;
496✔
2759
                }
2760
            }
2761

2762
            response.body = {
20✔
2763
                targets: [...completions.values()]
2764
            };
2765
        } catch (error) {
2766
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2767
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
UNCOV
2768
            this.logger.error('Error during completionsRequest', error, { args });
×
2769
        }
2770
        this.sendResponse(response);
20✔
2771
    }
2772

2773
    /**
2774
     * Gets the closest completion details the incoming completion request.
2775
     */
2776
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2777
        const incomingText = args.text;
69✔
2778
        const lines = incomingText.split('\n');
69✔
2779
        let lineNumber = this.toDebuggerLine(args.line, 0);
69✔
2780
        let column = this.toDebuggerColumn(args.column);
69✔
2781

2782
        const targetLine = lines[lineNumber] ?? '';
69!
2783

2784
        const cursorIndex = column - 1;
69✔
2785
        const variableChars = /[a-z0-9_\.]/i;
69✔
2786

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

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

2802
        const openBracket = this.findUnclosedOpener(targetLine, column);
67✔
2803
        if (openBracket?.char === '[') {
67✔
2804
            // find the opening quote (skipping any whitespace after the `[`)
2805
            let quoteIndex = openBracket.index + 1;
12✔
2806
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12✔
UNCOV
2807
                quoteIndex++;
×
2808
            }
2809
            const quote = targetLine[quoteIndex];
12✔
2810
            if (quote === '"' || quote === `'`) {
12✔
2811
                // The user is typing a string key, so complete the keys of the expression before the `[`
2812
                endColumn = openBracket.index;
8✔
2813
                isMemberAccess = true;
8✔
2814
                // close the string and bracket only when there is nothing meaningful after the cursor
2815
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8✔
2816
            }
2817
        }
2818

2819
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2820
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2821
        let startIndex = endColumn - 1;
67✔
2822
        let bracketDepth = 0;
67✔
2823
        while (startIndex >= 0) {
67✔
2824
            const char = targetLine[startIndex];
438✔
2825
            if (char === ']') {
438✔
2826
                bracketDepth++;
10✔
2827
            } else if (char === '[') {
428✔
2828
                if (bracketDepth === 0) {
12✔
2829
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2830
                    break;
2✔
2831
                }
2832
                bracketDepth--;
10✔
2833
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416✔
2834
                break;
23✔
2835
            }
2836
            startIndex--;
413✔
2837
        }
2838

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

2841
        // Attempted dot access on something unexpected.
2842
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2843
        // which leaves `.name` as the variable path string.
2844
        if (variablePathString.startsWith('.')) {
67✔
2845
            return undefined;
2✔
2846
        }
2847

2848
        if (variablePathString.endsWith('.')) {
65✔
2849
            isMemberAccess = true;
25✔
2850
        }
2851

2852
        // Get the variable path from the text
2853
        let variablePath: string[];
2854
        if (!variablePathString.trim()) {
65✔
2855
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2856
            variablePath = [''];
10✔
2857
        } else if (variablePathString.endsWith('.')) {
55✔
2858
            // supplied text ends with a period, so strip it off to create a valid variable path
2859
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25✔
2860
        } else {
2861
            variablePath = util.getVariablePath(variablePathString);
30✔
2862
        }
2863

2864
        // the target string is not a valid variable path
2865
        if (!variablePath) {
65✔
2866
            return undefined;
2✔
2867
        }
2868

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

2873
        // An empty parent path means we are looking up the local scope variables and global functions
2874
        if (parentVariablePath.length === 0) {
63✔
2875
            parentVariablePath = [''];
17✔
2876
        }
2877

2878
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63✔
2879
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2880
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63✔
2881
            result.stringKeyClosing = stringKeyClosing;
8✔
2882
        }
2883
        return result;
63✔
2884
    }
2885

2886
    /**
2887
     * Compute the span of input text that a completion replaces: the run of identifier characters
2888
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2889
     * typing past the first character.
2890
     *
2891
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2892
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2893
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2894
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2895
     */
2896
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2897
        const lines = args.text.split('\n');
20✔
2898
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2899
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2900
        const targetLine = lines[lineNumber] ?? '';
20!
2901

2902
        const identifierChars = /[a-z0-9_]/i;
20✔
2903
        let wordStart = cursorOffset;
20✔
2904
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2905
            wordStart--;
7✔
2906
        }
2907
        return {
20✔
2908
            start: wordStart,
2909
            length: cursorOffset - wordStart
2910
        };
2911
    }
2912

2913
    /**
2914
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2915
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2916
     */
2917
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2918
        let depth = 0;
67✔
2919
        for (let i = column - 1; i >= 0; i--) {
67✔
2920
            const char = line[i];
604✔
2921
            if (char === ')' || char === ']' || char === '}') {
604✔
2922
                depth++;
12✔
2923
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2924
                if (depth === 0) {
34✔
2925
                    return { index: i, char: char };
22✔
2926
                }
2927
                depth--;
12✔
2928
            }
2929
        }
2930
        return undefined;
45✔
2931
    }
2932

2933
    /**
2934
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2935
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2936
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2937
     */
2938
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2939
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2940
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2941
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20✔
2942
        if (isLocalScope) {
20✔
2943
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2944
            if (!localsScope.isResolved) {
4!
2945
                try {
4✔
2946
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2947
                } catch (error) {
UNCOV
2948
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2949
                }
2950
            }
2951
        }
2952

2953
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2954
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2955
            return inMemory;
14✔
2956
        }
2957

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

2962
        const cacheKey = `${frameId}:${expression}`;
6✔
2963
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2964
            return this.completionParentVariableCache.get(cacheKey);
1✔
2965
        }
2966

2967
        let parentVariable: AugmentedVariable;
2968
        try {
5✔
2969
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2970
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2971
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
2972
        } catch (error) {
2973
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2974
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1✔
2975
            parentVariable = undefined;
1✔
2976
        }
2977

2978
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2979
        return parentVariable;
5✔
2980
    }
2981

2982
    /**
2983
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2984
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2985
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
2986
     * use dot access. This keeps array indices and string keys correct through the device lookup.
2987
     */
2988
    private buildVariableExpression(segments: string[]): string {
2989
        return segments.reduce((expression, segment, index) => {
14✔
2990
            if (index === 0) {
27✔
2991
                return segment;
14✔
2992
            }
2993
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2994
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
2995
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
2996
                return `${expression}[${segment}]`;
2✔
2997
            }
2998
            if (/^[0-9]+$/.test(segment)) {
11✔
2999
                return `${expression}[${segment}]`;
3✔
3000
            }
3001
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
3002
                return `${expression}.${segment}`;
5✔
3003
            }
3004
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
3005
        }, '');
3006
    }
3007

3008
    /**
3009
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
3010
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
3011
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
3012
     * the user typed (ex: `topRef` matching the cached `topref`).
3013
     */
3014
    private normalizeVariableName(name: string): string {
3015
        let value = name ?? '';
46!
3016
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46✔
3017
            value = value.slice(1, -1).replace(/""/g, '"');
2✔
3018
        }
3019
        return value.toLowerCase();
46✔
3020
    }
3021

3022
    /**
3023
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
3024
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
3025
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
3026
     */
3027
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
3028
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
3029
        if (path.length === 1 && path[0] === '') {
20✔
3030
            return localsContainer;
4✔
3031
        }
3032
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
3033
    }
3034

3035
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
3036
        let current: AugmentedVariable = null;
22✔
3037
        for (const name of path) {
22✔
3038
            const normalizedName = this.normalizeVariableName(name);
26✔
3039
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
3040
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26!
3041
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20✔
3042
            });
3043

3044
            // If no match is found, return null
3045
            if (!current) {
26✔
3046
                return null;
7✔
3047
            }
3048

3049
            // Move to the children for the next iteration
3050
            variables = current.childVariables;
19✔
3051
        }
3052
        return current;
15✔
3053
    }
3054

3055
    private debuggerVarTypeToRoType(type: string): string {
3056
        switch (type) {
15!
3057
            case VariableType.Function:
3058
            case VariableType.Subroutine:
3059
                return 'roFunction';
×
3060
            case VariableType.AssociativeArray:
3061
                return 'roAssociativeArray';
11✔
3062
            case VariableType.List:
UNCOV
3063
                return 'roList';
×
3064
            case VariableType.Array:
3065
                return 'roArray';
1✔
3066
            case VariableType.Boolean:
UNCOV
3067
                return 'roBoolean';
×
3068
            case VariableType.Double:
UNCOV
3069
                return 'roDouble';
×
3070
            case VariableType.Float:
UNCOV
3071
                return 'roFloat';
×
3072
            case VariableType.Integer:
3073
                return 'roInteger';
×
3074
            case VariableType.LongInteger:
UNCOV
3075
                return 'roLongInteger';
×
3076
            case VariableType.String:
UNCOV
3077
                return 'roString';
×
3078
            default:
3079
                return type;
3✔
3080
        }
3081
    }
3082

3083
    /**
3084
     * Called when the host stops debugging
3085
     * @param response
3086
     * @param args
3087
     */
3088
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
3089
        //return to the home screen — best effort. The device may already be powered off or unreachable
3090
        //at disconnect time; without a guard pressHomeButton rejects (EHOSTDOWN / ECONNREFUSED / etc)
3091
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
3092
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
3093
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
3094
        //    https://github.com/rokucommunity/roku-debug/issues/332
3095
        if (!this.enableDebugProtocol) {
2!
3096
            try {
2✔
3097
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
3098
            } catch (e) {
3099
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
3100
            }
3101
        }
3102
        this.sendResponse(response);
2✔
3103
        await this.shutdown();
2✔
3104
    }
3105

3106
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
3107
        if (this.enableDebugProtocol) {
1!
UNCOV
3108
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
3109
        } else {
3110
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1✔
3111
        }
3112
    }
3113

3114
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
UNCOV
3115
        this.logger.log('[restartRequest] begin');
×
UNCOV
3116
        if (this.rokuAdapter) {
×
UNCOV
3117
            if (!this.enableDebugProtocol) {
×
3118
                this.rokuAdapter.removeAllListeners();
×
3119
            }
3120
            await this.rokuAdapter.destroy();
×
3121
            await this.ensureAppIsInactive();
×
3122
            this.rokuAdapterDeferred = defer();
×
UNCOV
3123
            this.stagingDefered.tryResolve();
×
UNCOV
3124
            this.stagingDefered = defer();
×
3125
        }
3126
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
3127
    }
3128

3129
    private exitAppTimeout = 5000;
217✔
3130
    private async ensureAppIsInactive() {
3131
        const startTime = Date.now();
×
3132

3133
        while (true) {
×
UNCOV
3134
            if (Date.now() - startTime > this.exitAppTimeout) {
×
UNCOV
3135
                return;
×
3136
            }
3137

UNCOV
3138
            try {
×
UNCOV
3139
                let appStateResult = await rokuECP.getAppState({
×
3140
                    host: this.launchConfiguration.host,
3141
                    remotePort: this.launchConfiguration.remotePort,
3142
                    appId: 'dev',
3143
                    requestOptions: { timeout: 300 }
3144
                });
3145

3146
                const state = appStateResult.state;
×
3147

UNCOV
3148
                if (state === AppState.active || state === AppState.background) {
×
3149
                    // Suspends or terminates an app that is running:
3150
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
3151
                    // 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.
3152
                    // This means that we might need to send this command twice to terminate the app.
UNCOV
3153
                    await rokuECP.exitApp({
×
3154
                        host: this.launchConfiguration.host,
3155
                        remotePort: this.launchConfiguration.remotePort,
3156
                        appId: 'dev',
3157
                        requestOptions: { timeout: 300 }
3158
                    });
UNCOV
3159
                } else if (state === AppState.inactive) {
×
UNCOV
3160
                    return;
×
3161
                }
3162
            } catch (e) {
UNCOV
3163
                this.logger.error('Error attempting to exit application', e);
×
3164
            }
3165

UNCOV
3166
            await util.sleep(200);
×
3167
        }
3168
    }
3169

3170
    /**
3171
     * Used to track whether the entry breakpoint has already been handled
3172
     */
3173
    private entryBreakpointWasHandled = false;
217✔
3174

3175
    /**
3176
     * Registers the main events for the RokuAdapter
3177
     */
3178
    private async connectRokuAdapter() {
UNCOV
3179
        this.rokuAdapter.on('start', () => {
×
UNCOV
3180
            this.sendLaunchProgress('end', 'Complete');
×
UNCOV
3181
            if (!this.firstRunDeferred.isCompleted) {
×
UNCOV
3182
                this.firstRunDeferred.resolve();
×
3183
            }
3184
        });
3185

UNCOV
3186
        this.rokuAdapter.on('launch-status', (message) => {
×
UNCOV
3187
            this.sendLaunchProgress('update', message);
×
3188
        });
3189

3190
        //when the debugger suspends (pauses for debugger input)
3191
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
UNCOV
3192
        this.rokuAdapter.on('suspend', async () => {
×
UNCOV
3193
            await this.onSuspend();
×
3194
        });
3195

3196
        //anytime the adapter encounters an exception on the roku,
3197
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
UNCOV
3198
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
UNCOV
3199
            await this.getRokuAdapter();
×
UNCOV
3200
            const threads = await this.setupSuspendedState();
×
UNCOV
3201
            let threadId = threads[0]?.threadId;
×
UNCOV
3202
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
3203
        });
3204

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

3210
        //make the connection
UNCOV
3211
        await this.rokuAdapter.connect();
×
UNCOV
3212
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
UNCOV
3213
        return this.rokuAdapter;
×
3214
    }
3215

3216
    private async onSuspend() {
3217
        const threads = await this.setupSuspendedState();
1✔
3218
        const activeThread = threads.find(x => x.isSelected);
1✔
3219

3220
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3221
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
3222
            this.entryBreakpointWasHandled = true;
1✔
3223
            //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
3224
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
UNCOV
3225
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
UNCOV
3226
                return this.rokuAdapter.continue();
×
3227
            }
3228
        }
3229

3230
        const event: StoppedEvent = new StoppedEvent(
1✔
3231
            StoppedEventReason.breakpoint,
3232
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
3233
            activeThread?.threadId ?? 0,
6!
3234
            '' //exception text
3235
        );
3236
        // Socket debugger will always stop all threads and supports multi thread inspection.
3237
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
3238
        this.sendEvent(event);
1✔
3239
    }
3240

3241
    private async setupSuspendedState() {
3242
        //clear the index for storing evalutated expressions
3243
        this.evaluateVarIndexByFrameId.clear();
8✔
3244

3245
        const threads = await this.rokuAdapter.getThreads();
8✔
3246

3247
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3248
        //look up the correct line numbers for each thread from the StackTrace
3249
        await Promise.all(
8✔
3250
            threads.map(async (thread) => {
3251
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3252
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3253
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
3254
                // Only apply the line correction when we actually have valid data — never clobber
3255
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3256
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9✔
3257
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3258
                    thread.lineNumber = stackTraceLineNumber;
2✔
3259
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3260
                }
3261
            })
3262
        );
3263

3264
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3265
            for (const thread of threads) {
4✔
3266
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
3267
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3268
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3269
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4✔
3270
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
UNCOV
3271
                        this.logger.error('Error showing popup message', { error });
×
3272
                    });
3273
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3274
                    break outer;
1✔
3275
                }
3276
            }
3277
        }
3278

3279
        //sync breakpoints
3280
        await this.rokuAdapter?.syncBreakpoints();
8!
3281

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

3284
        this.clearState();
8✔
3285
        return threads;
8✔
3286
    }
3287

3288
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3289
        let v: AugmentedVariable;
3290

3291
        if (result) {
25!
3292
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3293
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3294
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
UNCOV
3295
                    try {
×
3296
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
UNCOV
3297
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
UNCOV
3298
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3299
                        this.mergeEvaluateContainers(result, newResult);
×
3300
                    } catch (error) {
UNCOV
3301
                        logger.error('Error getting variables', error);
×
UNCOV
3302
                        this.mergeEvaluateContainers(result, {
×
3303
                            name: result.name,
3304
                            evaluateName: result.evaluateName,
3305
                            children: [],
3306
                            value: `❌ Error: ${error.message}`,
3307
                            type: '',
3308
                            highLevelType: undefined,
3309
                            keyType: undefined
3310
                        });
3311
                    }
3312
                }
3313

3314
                if (result.keyType) {
16✔
3315
                    let value = `${result.value ?? result.type}`;
5!
3316
                    let indexedVariables = result.indexedVariables;
5✔
3317
                    let namedVariables = result.namedVariables;
5✔
3318

3319
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
3320
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3321
                        // by supplying undefined values for both
3322
                        indexedVariables = undefined;
×
3323
                        namedVariables = undefined;
×
3324
                    }
3325

3326
                    // check to see if this is an dictionary or a list
3327
                    if (result.keyType === 'Integer') {
5!
3328
                        // list type
3329
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3330
                    } else if (result.keyType === 'String') {
5!
3331
                        // dictionary type
3332
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5✔
3333
                    }
3334
                    v.type = result.type;
5✔
3335
                } else {
3336

3337
                    let value: string;
3338
                    if (result.type === VariableType.Invalid) {
11!
UNCOV
3339
                        value = result.value ?? 'Invalid';
×
3340
                    } else if (result.type === VariableType.Uninitialized) {
11!
UNCOV
3341
                        value = 'Uninitialized';
×
3342
                    } else {
3343
                        value = `${result.value}`;
11✔
3344
                    }
3345
                    // If the variable is lazy we must assign a refId to inform the system
3346
                    // to request this variable again in the future for value resolution
3347
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11!
3348
                }
3349
                this.variables[refId] = v;
16✔
3350
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3351
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3352
                    v = new Variable(result.name, `${result.value}`);
7✔
3353
                } else if (result.highLevelType === 'array') {
2✔
3354
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3355
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3356
                    this.variables[refId] = v;
1✔
3357
                } else if (result.highLevelType === 'object') {
1!
3358
                    let refId: number;
3359
                    //handle collections
3360
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3361
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3362
                    }
3363
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3364
                    this.variables[refId] = v;
1✔
3365
                } else if (result.highLevelType === 'function') {
×
3366
                    v = new Variable(result.name, `${result.value}`);
×
3367
                } else {
3368
                    //all other cases, but mostly for HighLevelType.unknown
UNCOV
3369
                    v = new Variable(result.name, `${result.value}`);
×
3370
                }
3371
            }
3372

3373
            v.type = result.type;
25✔
3374
            v.evaluateName = result.evaluateName;
25✔
3375
            v.frameId = frameId;
25✔
3376
            v.type = result.type;
25✔
3377
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25!
3378
            if (util.isTransientVariable(v.name)) {
25!
UNCOV
3379
                v.presentationHint = { kind: 'virtual' };
×
3380
            }
3381

3382
            if (result.children && maxDepth > 0) {
25✔
3383
                if (!v.childVariables) {
7!
3384
                    v.childVariables = [];
7✔
3385
                }
3386

3387
                // Create a mapping of the children to their index so we can evaluate them in bulk
3388
                let indexMappedChildren = result.children.map((child, index) => {
7✔
3389
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3390
                    return remapped;
10✔
3391
                });
3392
                if (this.enableDebugProtocol) {
7!
UNCOV
3393
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
UNCOV
3394
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
UNCOV
3395
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3396
                    });
3397

UNCOV
3398
                    let variablePathArray = childrenToEvaluate.map(x => {
×
UNCOV
3399
                        return util.getVariablePath(x.child.evaluateName);
×
3400
                    });
3401

UNCOV
3402
                    try {
×
UNCOV
3403
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
UNCOV
3404
                        if (bulkEvaluations.bulkVarName) {
×
UNCOV
3405
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
UNCOV
3406
                            childrenToEvaluate.map((mappedChild, index) => {
×
UNCOV
3407
                                let newResult = newResults.children[index];
×
UNCOV
3408
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
UNCOV
3409
                                mappedChild.child.evaluateNow = false;
×
UNCOV
3410
                                return mappedChild;
×
3411
                            });
3412
                        }
3413
                    } catch (error) {
UNCOV
3414
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3415
                    }
3416
                }
3417
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3418
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7✔
3419
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3420
                }));
3421
            } else {
3422
                v.childVariables = [];
18✔
3423
            }
3424

3425
            // if the var is an array and debugProtocol is enabled, include the array size
3426
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25!
UNCOV
3427
                if (isNaN(result.indexedVariables as number)) {
×
UNCOV
3428
                    v.value = v.type;
×
3429
                } else {
UNCOV
3430
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3431
                }
3432
            }
3433
        }
3434
        return v;
25✔
3435
    }
3436

3437
    /**
3438
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3439
     * Used primarily for custom variables
3440
     */
3441
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
UNCOV
3442
        original.children = updated.children;
×
UNCOV
3443
        original.value = updated.value;
×
UNCOV
3444
        original.type = updated.type;
×
UNCOV
3445
        original.highLevelType = updated.highLevelType;
×
UNCOV
3446
        original.keyType = updated.keyType;
×
UNCOV
3447
        original.indexedVariables = updated.indexedVariables;
×
UNCOV
3448
        original.namedVariables = updated.namedVariables;
×
3449
    }
3450

3451
    private getEvaluateRefId(expression: string, frameId: number) {
3452
        let evaluateRefId = `${expression}-${frameId}`;
77✔
3453
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77✔
3454
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45✔
3455
        }
3456
        return this.evaluateRefIdLookup[evaluateRefId];
77✔
3457
    }
3458

3459
    private clearState() {
3460
        //erase all cached variables
3461
        this.variables = {};
12✔
3462
        this.completionParentVariableCache.clear();
12✔
3463
    }
3464

3465
    /**
3466
     * Sends a launch progress event to the client if the client supports progress reporting.
3467
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3468
     * - `'update'`: updates the message on the active progress bar.
3469
     * - `'end'`: dismisses the active progress bar with an optional final message.
3470
     */
3471
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3472
        if (!this.initRequestArgs?.supportsProgressReporting) {
75✔
3473
            return;
44✔
3474
        }
3475
        if (type === 'start') {
31✔
3476
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
10✔
3477
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
10✔
3478
        } else if (this.launchProgressId) {
21✔
3479
            if (type === 'update') {
18✔
3480
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3481
            } else {
3482
                const lastId = this.launchProgressId;
5✔
3483
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
5✔
3484
                setTimeout(() => {
5✔
3485
                    this.sendEvent(new ProgressEndEvent(lastId, message));
5✔
3486
                }, 1000); // add a slight delay before ending the progress to improve UX
3487
                this.launchProgressId = undefined;
5✔
3488
            }
3489
        }
3490
    }
3491

3492
    /**
3493
     * Tells the client to re-request all variables because we've invalidated them
3494
     * @param threadId
3495
     * @param stackFrameId
3496
     */
3497
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3498
        //if the client supports this request, send it
3499
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3!
UNCOV
3500
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3501
        }
3502
    }
3503

3504
    /**
3505
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3506
     */
3507
    public async handleEntryBreakpoint() {
3508
        if (!this.enableDebugProtocol) {
4!
3509
            this.entryBreakpointWasHandled = true;
4✔
3510
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3511
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
3512
            }
3513
        }
3514
    }
3515

3516
    /**
3517
     * Converts a debugger line number to a client line number.
3518
     *
3519
     * @param debuggerLine - The line number from the debugger as zero based.
3520
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3521
     * @returns The corresponding client line number.
3522
     */
3523
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3524
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3!
3525
    }
3526

3527
    /**
3528
     * Converts a debugger column number to a client column number.
3529
     *
3530
     * @param debuggerLine - The column number from the debugger as zero based.
3531
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3532
     * @returns The corresponding client column number.
3533
     */
3534
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3535
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3!
3536
    }
3537

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

3552
    /**
3553
     * Converts a client column number to a debugger column number.
3554
     *
3555
     * @param clientLine - The column number from the client.
3556
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3557
     * @returns The corresponding debugger column number as zero based.
3558
     */
3559
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3560
        if (typeof clientLine === 'number') {
89!
3561
            return this.convertClientColumnToDebugger(clientLine);
89✔
3562
        }
UNCOV
3563
        return defaultDebuggerLine;
×
3564
    }
3565

3566
    private shutdownPromise: Promise<void> | undefined = undefined;
217✔
3567

3568
    /**
3569
     * 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
3570
     * the same promise on subsequent calls
3571
     */
3572
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
16✔
3573
        if (this.shutdownPromise === undefined) {
16!
3574
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
16✔
3575
            //Backstop: if the graceful shutdown hangs (e.g. pressHomeButton against an unreachable
3576
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3577
            const forceExitTimer = setTimeout(() => {
16✔
3578
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
3579
                this.forceExit();
×
3580
            }, this.shutdownForceExitTimeout);
3581
            forceExitTimer.unref?.();
16!
3582
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
16✔
3583
                clearTimeout(forceExitTimer);
16✔
3584
            });
3585
        } else {
UNCOV
3586
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3587
        }
3588
        return this.shutdownPromise;
16✔
3589
    }
3590

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

3595
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3596
        try {
16✔
3597
            if (errorMessage) {
16!
UNCOV
3598
                this.logger.error(errorMessage);
×
UNCOV
3599
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
UNCOV
3600
                    this.logger.error('Error showing popup message', { error });
×
3601
                });
3602
            }
3603
        } catch (e) {
UNCOV
3604
            this.logger.error(e);
×
3605
        }
3606
        // stop perfetto tracing if it's running
3607
        try {
16✔
3608
            await this.perfettoManager.stopTracing();
16✔
3609
        } catch (e) {
3610
            this.logger.error('Error stopping perfetto tracing', e);
16✔
3611
        }
3612

3613
        try {
16✔
3614
            await this.perfettoManager?.dispose?.();
16!
3615
        } catch (e) {
UNCOV
3616
            this.logger.error('Error disposing perfetto manager', e);
×
3617
        }
3618

3619
        //close the debugger connection
3620
        try {
16✔
3621
            this.logger.log('Destroy rokuAdapter');
16✔
3622
            await this.rokuAdapter?.destroy?.();
16!
3623
            //press the home button to return to the home screen
3624
            try {
16✔
3625
                this.logger.log('Press home button');
16✔
3626
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
16✔
3627
            } catch (e) {
UNCOV
3628
                this.logger.error(e);
×
3629
            }
3630
        } catch (e) {
UNCOV
3631
            this.logger.error(e);
×
3632
        }
3633

3634
        try {
16✔
3635
            this.projectManager?.dispose?.();
16!
3636
        } catch (e) {
UNCOV
3637
            this.logger.error(e);
×
3638
        }
3639

3640
        try {
16✔
3641
            this.componentLibraryServer?.stop();
16!
3642
        } catch (e) {
UNCOV
3643
            this.logger.error(e);
×
3644
        }
3645

3646
        try {
16✔
3647
            await this.rendezvousTracker?.destroy?.();
16!
3648
        } catch (e) {
UNCOV
3649
            this.logger.error(e);
×
3650
        }
3651

3652
        try {
16✔
3653
            await this.sourceMapManager?.destroy?.();
16!
3654
        } catch (e) {
UNCOV
3655
            this.logger.error(e);
×
3656
        }
3657

3658
        try {
16✔
3659
            //if configured, delete the staging directory
3660
            if (!this.launchConfiguration.retainStagingFolder) {
16!
3661
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
16!
3662
                this.logger.info('deleting staging folders', stagingDirs);
16✔
3663
                for (let stagingDir of stagingDirs) {
16✔
3664
                    try {
2✔
3665
                        fsExtra.removeSync(stagingDir);
2✔
3666
                    } catch (e) {
UNCOV
3667
                        this.logger.error(e);
×
UNCOV
3668
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3669
                    }
3670
                }
3671
            }
3672
        } catch (e) {
UNCOV
3673
            this.logger.error(e);
×
3674
        }
3675

3676
        try {
16✔
3677
            this.logger.log('Send terminated event');
16✔
3678
            this.sendEvent(new TerminatedEvent());
16✔
3679

3680
            //shut down the process
3681
            this.logger.log('super.shutdown()');
16✔
3682
            super.shutdown();
16✔
3683
            this.logger.log('shutdown complete');
16✔
3684
        } catch (e) {
UNCOV
3685
            this.logger.error(e);
×
3686
        }
3687

3688
        try {
16✔
3689
            this.teardownProcessErrorHandlers();
16✔
3690
        } catch (e) {
UNCOV
3691
            this.logger.error(e);
×
3692
        }
3693
    }
3694
}
3695

3696
export interface AugmentedVariable extends DebugProtocol.Variable {
3697
    childVariables?: AugmentedVariable[];
3698
    // eslint-disable-next-line camelcase
3699
    request_seq?: number;
3700
    frameId?: number;
3701
    /**
3702
     * only used for lazy variables
3703
     */
3704
    isResolved?: boolean;
3705
    /**
3706
     * used to indicate that this variable is a scope variable
3707
     * and may require special handling
3708
     */
3709
    isScope?: boolean;
3710
}
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