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

rokucommunity / roku-debug / 28819368783

06 Jul 2026 07:58PM UTC coverage: 73.029% (+0.3%) from 72.68%
28819368783

Pull #323

github

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

3846 of 5496 branches covered (69.98%)

Branch coverage included in aggregate %.

93 of 120 new or added lines in 3 files covered. (77.5%)

355 existing lines in 1 file now uncovered.

6021 of 8015 relevant lines covered (75.12%)

47.42 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

166
    private handlingProcessError = false;
215✔
167

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
342

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

352
    public breakpointManager: BreakpointManager;
353

354
    public locationManager: LocationManager;
355

356
    public sourceMapManager: SourceMapManager;
357

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

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

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

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

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

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

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

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
387

388
    private perfettoManager: PerfettoManager;
389

390
    private rendezvousTracker: RendezvousTracker;
391

392
    public tempVarPrefix = '__rokudebug__';
215✔
393

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

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

405
    /**
406
     * A magic number to represent a fake thread that will be used for showing compile errors in the UI as if they were runtime crashes
407
     */
408
    private COMPILE_ERROR_THREAD_ID = 7_777;
215✔
409

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

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

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

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

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

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

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

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

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

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

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

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

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

485
        this.sendResponse(response);
2✔
486

487
        //register the debug output log transport writer
488
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
2✔
489
            this.sendEvent(
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;
215✔
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();
215✔
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) {
11✔
1495
            return;
2✔
1496
        }
1497
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1498
        //make sure this folder exists (and is empty)
1499
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1500
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1501

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

1506
            this.projectManager.componentLibraryProjects.push(
19✔
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(
9✔
1526
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
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) {
10✔
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');
8✔
1543

1544
        //validate breakpoints (and write STOPs for telnet) and postfix each complib's own files in parallel
1545
        await Promise.all(
8✔
1546
            this.projectManager.componentLibraryProjects.map(async (compLibProject) => {
1547
                await this.breakpointManager.validateAndWriteBreakpointsForProject(compLibProject);
18✔
1548
                await compLibProject.postfixFiles();
18✔
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) {
10✔
1560
            return;
2✔
1561
        }
1562
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8✔
1563

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

1572
        //seal every complib's zip in parallel — each targets distinct files
1573
        const packagePromises = this.projectManager.componentLibraryProjects.map(
8✔
1574
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
18✔
1575
        );
1576

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1784
        let threads = [];
4✔
1785

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

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

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

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

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

1816
        }
1817

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

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

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

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

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

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

1861
        return threadName;
24✔
1862
    }
1863

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

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

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

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

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

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

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

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

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

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

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

UNCOV
1985
            scopes.push(localScope);
×
1986

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2171
            // Populate scope level values if needed
2172
            if (v.isScope) {
4✔
2173
                await this.populateScopeVariables(v, args);
2✔
2174
            }
2175

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

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

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

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

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

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

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

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

2261

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

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

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

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

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

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

2300
        return filteredUpdatedVariables;
4✔
2301
    }
2302

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
2557
        if (command) {
×
2558

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

UNCOV
2568
            command += bulkContainerStatement;
×
2569

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

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

UNCOV
2578
        return results;
×
2579
    }
2580

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3132
                const state = appStateResult.state;
×
3133

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3300
                if (result.keyType) {
16✔
3301
                    let value = `${result.value ?? result.type}`;
5!
3302
                    let indexedVariables = result.indexedVariables;
5✔
3303
                    let namedVariables = result.namedVariables;
5✔
3304

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3552
    private shutdownPromise: Promise<void> | undefined = undefined;
215✔
3553

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

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

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

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

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

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

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

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

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

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

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

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

3674
        try {
16✔
3675
            this.teardownProcessErrorHandlers();
16✔
3676
        } catch (e) {
UNCOV
3677
            this.logger.error(e);
×
3678
        }
3679
    }
3680
}
3681

3682
export interface AugmentedVariable extends DebugProtocol.Variable {
3683
    childVariables?: AugmentedVariable[];
3684
    // eslint-disable-next-line camelcase
3685
    request_seq?: number;
3686
    frameId?: number;
3687
    /**
3688
     * only used for lazy variables
3689
     */
3690
    isResolved?: boolean;
3691
    /**
3692
     * used to indicate that this variable is a scope variable
3693
     * and may require special handling
3694
     */
3695
    isScope?: boolean;
3696
}
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