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

rokucommunity / roku-debug / 36172775644

25 Sep 2026 06:20PM UTC coverage: 73.498% (+0.08%) from 73.423%
36172775644

Pull #425

github

web-flow
Merge 350b25c34 into a23151f1e
Pull Request #425: Gate perfetto connectOnStart on tracing being enabled

3915 of 5569 branches covered (70.3%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 1 file covered. (100.0%)

305 existing lines in 1 file now uncovered.

6127 of 8094 relevant lines covered (75.7%)

51.49 hits per line

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

67.68
/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, SideloadOptions } 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 type { ResolvedLaunchConfiguration } from '../LaunchConfiguration';
62
import { BreakpointManager } from '../managers/BreakpointManager';
2 ✔
63
import type { LogMessage } from '../logging';
64
import { PerfettoManager } from '../PerfettoManager';
2 ✔
65
import { logger, FileLoggingManager, debugServerLogOutputEventTransport } from '../logging';
2 ✔
66
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
2 ✔
67
import { DiagnosticSeverity } from 'brighterscript';
2 ✔
68
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
69
import { debounce } from 'debounce';
2 ✔
70
import { interfaces, components, events } from 'brighterscript/dist/roku-types';
2 ✔
71
import { globalCallables } from 'brighterscript/dist/globalCallables';
2 ✔
72
import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool';
2 ✔
73
import { populateVariableFromRegistryEcp } from './ecpRegistryUtils';
2 ✔
74
import { AppState, rokuECP } from '../RokuECP';
2 ✔
75
import { SocketConnectionInUseError } from '../Exceptions';
2 ✔
76

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

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

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

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

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

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

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

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

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

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

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

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

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

167
    private handlingProcessError = false;
229 ✔
168

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

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

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

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

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

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

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

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

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

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

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

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

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

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

329
    public logger = logger.createLogger(`[session]`);
229 ✔
330

331
    private readonly isWindowsPlatform = process.platform.startsWith('win');
229 ✔
332

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

338
    public fileManager: FileManager;
339

340
    public projectManager: ProjectManager;
341

342
    public fileLoggingManager: FileLoggingManager;
343

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

353
    public breakpointManager: BreakpointManager;
354

355
    public locationManager: LocationManager;
356

357
    public sourceMapManager: SourceMapManager;
358

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

362
    /**
363
     * A short human-readable identifier for the target device, safe for log and error messages
364
     * (never includes credentials like the rceToken)
365
     */
366
    private get deviceLabel(): string {
367
        return util.getDeviceLabel(this.launchConfiguration.device);
17 ✔
368
    }
369

370
    private componentLibraryServer = new ComponentLibraryServer();
229 ✔
371

372
    private rokuAdapterDeferred = defer<DebugProtocolAdapter | TelnetAdapter>();
229 ✔
373
    /**
374
     * A promise that is resolved whenever the app has started running for the first time
375
     */
376
    private firstRunDeferred = defer<void>();
229 ✔
377

378
    /**
379
     * Resolved whenever we're finished copying all the files to staging for all projects
380
     */
381
    private stagingDefered = defer<void>();
229 ✔
382

383
    private evaluateRefIdLookup: Record<string, number> = {};
229 ✔
384
    private evaluateRefIdCounter = 1;
229 ✔
385

386
    private variables: Record<number, AugmentedVariable> = {};
229 ✔
387

388
    /**
389
     * Caches the device lookups performed while resolving completion requests. Variables don't change
390
     * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
391
     * `clearState` whenever the debugger resumes or steps.
392
     */
393
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
229 ✔
394

395
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
396

397
    private perfettoManager: PerfettoManager;
398

399
    private rendezvousTracker: RendezvousTracker;
400

401
    public tempVarPrefix = '__rokudebug__';
229 ✔
402

403
    /**
404
     * The progressId of the active launch progress bar, if any.
405
     * Cleared once the ProgressEndEvent is sent.
406
     */
407
    private launchProgressId: string | undefined;
408

409
    /**
410
     * Sends the deferred ProgressEndEvent for the launch progress bar (sendLaunchProgress holds it
411
     * back for UX). Kept here so shutdown can flush it immediately: the adapter can exit before the
412
     * delay elapses, which would otherwise leave the client's progress notification stuck open.
413
     * Cleared once the event has been sent.
414
     */
415
    private flushLaunchProgressEnd: (() => void) | undefined;
416

417
    /**
418
     * The first encountered compile error, will be used to send to the client as a runtime error (nicer UI presentation)
419
     */
420
    private compileError: BSDebugDiagnostic;
421

422
    /**
423
     * 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
424
     */
425
    private COMPILE_ERROR_THREAD_ID = 7_777;
229 ✔
426

427
    private get enableDebugProtocol() {
428
        return this.launchConfiguration?.enableDebugProtocol;
78 !
429
    }
430

431
    /**
432
     * Check if the Roku firmware supports Perfetto tracing (requires OS 15.2 or higher)
433
     */
434
    private get supportsPerfettoTracing() {
435
        this.logger.log('Checking if device supports Perfetto tracing', this.deviceInfo.softwareVersion);
13 ✔
436
        return semver.satisfies(this.deviceInfo?.softwareVersion ?? '0.0', '>= 15.2');
13 !
437
    }
438

439
    /**
440
     * Get a promise that resolves when the roku adapter is ready to be used
441
     */
442
    private async getRokuAdapter() {
443
        await this.rokuAdapterDeferred.promise;
26 ✔
444
        await this.rokuAdapter.onReady();
26 ✔
445
        return this.rokuAdapter;
26 ✔
446
    }
447

448
    /**
449
     * The normalized launch config for this session. The resolved type has no `host` (so nothing in
450
     * the session can read or write it) and a concrete `device` config, which is the only way the
451
     * debugger addresses the device. The raw `LaunchConfiguration` exists only as launchRequest's
452
     * DAP input; normalizeLaunchConfig converts it.
453
     */
454
    private launchConfiguration: ResolvedLaunchConfiguration;
455
    private initRequestArgs: DebugProtocol.InitializeRequestArguments;
456

457
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
229 ✔
458

459
    /**
460
     * The 'initialize' request is the first request called by the frontend
461
     * to interrogate the features the debug adapter provides.
462
     */
463
    public initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void {
464
        this.initRequestArgs = args;
2 ✔
465
        this.logger.log('initializeRequest');
2 ✔
466

467
        response.body ||= {};
2 ✔
468

469
        // This debug adapter implements the configurationDoneRequest.
470
        response.body.supportsConfigurationDoneRequest = true;
2 ✔
471

472
        // 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.
473
        response.body.supportsRestartRequest = true;
2 ✔
474

475
        // make VS Code to use 'evaluate' when hovering over source
476
        response.body.supportsEvaluateForHovers = true;
2 ✔
477

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

485
        //surface the filter list here so VS Code's BREAKPOINTS panel renders the checkboxes - the
486
        //panel only reads this list from the initialize response, not from later CapabilitiesEvents.
487
        //The `supportsExceptionFilterOptions` / `supportsExceptionOptions` booleans are deferred and
488
        //sent via CapabilitiesEvent once we know the connected device's protocol version.
489
        response.body.exceptionBreakpointFilters = [{
2 ✔
490
            filter: 'caught',
491
            supportsCondition: true,
492
            conditionDescription: '__brs_err__.rethrown = true',
493
            label: 'Caught Exceptions',
494
            description: `Breaks on all errors, even if they're caught later.`,
495
            default: false
496
        }, {
497
            filter: 'uncaught',
498
            supportsCondition: true,
499
            conditionDescription: '__brs_err__.rethrown = true',
500
            label: 'Uncaught Exceptions',
501
            description: 'Breaks only on errors that are not handled.',
502
            default: true
503
        }];
504

505
        response.body.supportsCompletionsRequest = true;
2 ✔
506
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
2 ✔
507

508
        this.sendResponse(response);
2 ✔
509

510
        //register the debug output log transport writer
511
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
2 ✔
512
            this.sendEvent(
558 ✔
513
                new DebugServerLogOutputEvent(
514
                    message.logger.formatMessage(message, false)
515
                )
516
            );
517
        });
518

519
        this.logger.log('initializeRequest finished');
2 ✔
520
    }
521

522
    protected async setExceptionBreakPointsRequest(response: DebugProtocol.SetExceptionBreakpointsResponse, args: DebugProtocol.SetExceptionBreakpointsArguments) {
523
        response.body ??= {};
9 !
524
        try {
9 ✔
525

526
            let filterOptions: ExceptionBreakpoint[];
527
            if (args.filterOptions) {
9 ✔
528
                filterOptions = args.filterOptions.map(x => ({
2 ✔
529
                    filter: x.filterId as 'caught' | 'uncaught',
530
                    conditionExpression: x.condition
531
                }));
532
            } else if (args.filters) {
7 ✔
533
                filterOptions = args.filters.map(x => ({
9 ✔
534
                    filter: x as 'caught' | 'uncaught'
535
                }));
536
            }
537
            this.exceptionBreakpoints = filterOptions;
9 ✔
538

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

544
            if (this.rokuAdapter.supportsExceptionBreakpoints) {
9 ✔
545
                //the adapter queues these filters internally if the debug protocol client hasn't
546
                //connected yet, and replays them once it does
547
                await this.rokuAdapter.setExceptionBreakpoints(filterOptions);
8 ✔
548
                response.body.breakpoints = [
7 ✔
549
                    { verified: true },
550
                    { verified: true }
551
                ];
552
            } else {
553
                response.body.breakpoints = [
1 ✔
554
                    { verified: false },
555
                    { verified: false }
556
                ];
557
            }
558
        } catch (e) {
559
            //if error (or not supported)
560
            response.body.breakpoints = [
1 ✔
561
                { verified: false },
562
                { verified: false }
563
            ];
564
            this.logger.error('Failed to set exception breakpoints', e);
1 ✔
565
        } finally {
566
            this.sendResponse(response);
9 ✔
567
        }
568
    }
569

570

571
    protected async setTransientsToInvalid() {
572
        let brsErr = Object.values(this.variables).find((v) => v.name === '__brs_err__');
×
573
        if (brsErr && brsErr.type !== VariableType.Uninitialized) {
×
574
            // Assigning the variable to the function call results in it becoming unintialized
575
            await this.rokuAdapter.evaluate(`__brs_err__ = [].clear()`, brsErr.frameId);
×
576
        }
577
    }
578

579
    private async showPopupMessage<T extends string>(message: string, severity: 'error' | 'warn' | 'info', modal = false, actions?: T[]): Promise<T> {
5 ✔
580
        const response = await this.sendCustomRequest('showPopupMessage', { message: message, severity: severity, modal: modal, actions: actions });
5 ✔
581
        return response.selectedAction;
3 ✔
582
    }
583

584
    private static requestIdSequence = 0;
2 ✔
585

586
    private async sendCustomRequest<T = any, R = any>(name: string, data: T): Promise<R> {
587
        const requestId = BrightScriptDebugSession.requestIdSequence++;
4 ✔
588
        const responsePromise = new Promise<R>((resolve, reject) => {
4 ✔
589
            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
4 ✔
590
                if (response.requestId === requestId) {
4 ✔
591
                    if (response.error) {
2 !
592
                        throw response.error;
×
593
                    } else {
594
                        resolve(response as R);
2 ✔
595
                    }
596
                }
597
            });
598
        });
599
        this.sendEvent(
4 ✔
600
            new CustomRequestEvent({
601
                requestId: requestId,
602
                name: name,
603
                ...data ?? {}
12 !
604
            }));
605
        return responsePromise;
4 ✔
606
    }
607

608
    /**
609
      * Get the cwd from the launchConfiguration, or default to process.cwd()
610
      */
611
    private get cwd() {
612
        return this.launchConfiguration?.cwd ?? process.cwd();
9 !
613
    }
614

615
    public deviceInfo: DeviceInfo;
616

617
    /**
618
     * Set defaults and standardize values for all of the LaunchConfiguration values
619
     * @param config
620
     * @returns
621
     */
622
    private normalizeLaunchConfig(config: LaunchConfiguration): ResolvedLaunchConfiguration {
623
        //`device` is the canonical way to address the target device; `host` is a deprecated alias.
624
        //this is the ONLY place the debugger reads the top-level `host` field: whatever was supplied
625
        //is resolved to a concrete device config here, and everything downstream uses `device`.
626
        if (!config.device && config.host) {
16 ✔
627
            config.device = { host: config.host };
4 ✔
628
        }
629
        //the deprecated field is now consumed. Delete it so the runtime object matches the resolved
630
        //type and nothing downstream (including the configs echoed back to the client) carries it.
631
        delete config.host;
16 ✔
632
        //an RCE device config without a token picks one up from the environment (the extension
633
        //injects ROKU_RCE_TOKEN into this process so the token does not have to travel through the
634
        //launch config over DAP). The custom events that echo this config back to the client scrub
635
        //the token out again (see Events.ts), so it never rides the DAP wire in either direction.
636
        config.device = util.hydrateRceTokenFromEnv(config.device);
16 ✔
637
        config.cwd ??= process.cwd();
16 ✔
638
        config.outDir ??= s`${config.cwd}/out`;
16 ✔
639
        config.stagingDir ??= util.getStagingDir({ outDir: config.outDir, cwd: config.cwd });
16 ✔
640
        config.componentLibrariesPort ??= 8080;
16 !
641
        config.packagePort ??= 80;
16 !
642
        config.remotePort ??= 8060;
16 !
643
        config.sceneGraphDebugCommandsPort ??= 8080;
16 !
644
        config.controlPort ??= 8081;
16 !
645
        config.brightScriptConsolePort ??= 8085;
16 !
646
        config.stagingDir ??= config.stagingFolderPath;
16 !
647
        config.emitChannelPublishedEvent ??= true;
16 !
648
        config.rewriteDevicePathsInLogs ??= true;
16 !
649
        config.autoResolveVirtualVariables ??= false;
16 !
650
        config.enhanceREPLCompletions ??= true;
16 !
651
        config.username ??= 'rokudev';
16 !
652
        if (config.profiling?.tracing) {
16 ✔
653
            // Default to true if not explicitly set
654
            config.profiling.tracing.enable ??= true;
2 ✔
655
            if (config.profiling.tracing.enable) {
2 ✔
656
                config.profiling.tracing.dir ??= s`${config.cwd}/traces/`;
1 !
657
                // eslint-disable-next-line no-template-curly-in-string
658
                config.profiling.tracing.filename ??= '${appTitle}_${timestamp}.perfetto-trace';
1 !
659
            }
660
        }
661

662
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
663
        if (typeof config.enableVariablesPanel !== 'boolean') {
16 !
664
            config.enableVariablesPanel = true;
16 ✔
665
        }
666
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
16 !
667
        return config as ResolvedLaunchConfiguration;
16 ✔
668
    }
669

670
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
671
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
10 ✔
672

673
        try {
10 ✔
674
            this.resetSessionState();
10 ✔
675
            this.launchConfiguration = this.normalizeLaunchConfig(config);
10 ✔
676
            //fail fast when the launch config supplied no device addressing at all, rather than
677
            //failing later with a confusing dns or connection error for an undefined host
678
            if (!this.launchConfiguration.device) {
10 ✔
679
                return await this.shutdown(`Launch config does not specify a target device. Please supply the 'device' option (or the deprecated 'host' option).`);
1 ✔
680
            }
681
            this.setupProcessErrorHandlers();
9 ✔
682

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

686
            //set the logLevel provided by the launch config
687
            if (this.launchConfiguration.logLevel) {
9 !
UNCOV
688
                logger.logLevel = this.launchConfiguration.logLevel;
×
689
            }
690

691
            this.sendLaunchProgress('start', 'Finding device on network');
9 ✔
692

693
            //do a DNS lookup for the host to fix issues with roku rejecting ECP. Only local devices
694
            //are addressed by host; other device types (like the Roku Cloud Emulator) pass through unchanged
695
            try {
9 ✔
696
                this.launchConfiguration.device = await this.rokuDeploy.withDnsResolvedHost(this.launchConfiguration.device);
9 ✔
697
            } catch (e) {
UNCOV
698
                return this.shutdown(`Could not resolve ip address for host '${this.deviceLabel}'`);
×
699
            }
700

701
            // fetch device info if not supplied via launch config
702
            try {
9 ✔
703
                if (this.launchConfiguration.deviceInfo) {
9 ✔
704
                    this.deviceInfo = rokuDeploy.enhanceDeviceInfo(this.launchConfiguration.deviceInfo);
1 ✔
705
                } else {
706
                    this.deviceInfo = await rokuDeploy.getDeviceInfo({ device: this.launchConfiguration.device, ecpPort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
8 ✔
707
                }
708
                if (this.deviceInfo.ecpSettingMode === 'limited') {
9 !
UNCOV
709
                    return await this.shutdown(`To allow the debugger to communicate properly, please ensure on the Roku device that 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive". Current mode: Limited (device: ${this.deviceLabel})`);
×
710
                }
711
            } catch (e) {
UNCOV
712
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
UNCOV
713
                    return this.shutdown(`To allow the debugger to communicate properly, please ensure on the Roku device that 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive". Current mode: Disabled (device: ${this.deviceLabel})`);
×
714
                }
UNCOV
715
                return this.shutdown(`Unable to connect to roku at '${this.deviceLabel}'. Verify the device address is correct and that the device is powered on and reachable.`);
×
716
            }
717

718
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
9 !
UNCOV
719
                return await this.shutdown(`Developer mode is not enabled for device '${this.deviceLabel}'.`);
×
720
            }
721

722
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
723
            this.sendResponse(response);
9 ✔
724

725
            //initialize all file logging (rokuDevice, debugger, etc)
726
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
9 !
727

728
            this.projectManager.launchConfiguration = this.launchConfiguration;
9 ✔
729
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
9 ✔
730

731
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
9 ✔
732

733
            this.logger.log('[launchRequest] Packaging and deploying to roku');
9 ✔
734
            const packageEnd = this.logger.timeStart('log', 'Packaging');
9 ✔
735
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
9 !
736
            //build the main project and all component libraries at the same time
737
            await Promise.all([
9 ✔
738
                this.prepareMainProject(),
739
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
740
            ]);
741

742
            //all of the projects have been successfully staged.
743
            this.stagingDefered.tryResolve();
9 ✔
744

745
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
746
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
9 !
UNCOV
747
                await this.sendCustomRequest('processStagingDir', {
×
748
                    projects: this.projectManager.getProjectStagingInfo()
749
                });
750
            }
751

752
            packageEnd();
9 ✔
753

754
            if (this.enableDebugProtocol) {
9 !
UNCOV
755
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.deviceLabel}:${this.launchConfiguration.controlPort}`);
×
756
            } else {
757
                util.log(`Connecting to Roku via telnet at ${this.deviceLabel}:${this.launchConfiguration.brightScriptConsolePort}`);
9 ✔
758
            }
759

760
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
761
            try {
9 ✔
762
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
9 ✔
763
                await this.initRendezvousTracking();
9 ✔
764
                rendezvousEnd();
9 ✔
765
            } catch (e) {
UNCOV
766
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
767
            }
768

769
            this.sendLaunchProgress('update', 'Connecting to debug server');
9 ✔
770
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
9 ✔
771
            this.createRokuAdapter(this.rendezvousTracker);
9 ✔
772

773
            // Manipulating complibs on-device autolaunches the dev app and often triggers compile errors,
774
            // so if we have at least one installable complib, delete the dev app and any complibs to avoid all that.
775
            if (this.launchConfiguration.componentLibraries?.some(x => x.install)) {
9 !
UNCOV
776
                this.sendLaunchProgress('update', 'Removing existing dev app and component libraries');
×
UNCOV
777
                await rokuDeploy.deleteAllSideloadedPlugins({
×
778
                    device: this.launchConfiguration.device,
779
                    password: this.launchConfiguration.password
780
                });
781
            }
782

783
            await this.connectRokuAdapter();
9 ✔
784
            connectAdapterEnd();
9 ✔
785

786
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
787
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
788
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
789
            // effect dynamically.
790
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
9 ✔
791
            this.sendEvent(new CapabilitiesEvent({
9 ✔
792
                supportsLogPoints: !this.enableDebugProtocol,
793
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
794
                supportsExceptionOptions: supportsExceptionBreakpoints,
795
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
796
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
797
            }));
798

799
            this.sendLaunchProgress('update', 'Configuring breakpoints');
9 ✔
800

801
            util.log('Done initializing');
9 ✔
802

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

807
            await this.initializeProfiling();
9 ✔
808

809
        } catch (e) {
810
            //if the message is anything other than compile errors, we want to display the error
UNCOV
811
            if (!(e instanceof CompileError)) {
×
UNCOV
812
                util.log('Encountered an issue during the launch process');
×
UNCOV
813
                util.log((e as Error)?.stack);
×
814

815
                //send any compile errors to the client
816
                await this.rokuAdapter?.sendErrors();
×
817

818
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
UNCOV
819
                await this.shutdown(message as string, true);
×
820
            } else {
821
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
822
            }
823
        }
824
        logEnd();
9 ✔
825
    }
826

827
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
828
        this.logger.log('configurationDoneRequest');
4 ✔
829
        super.configurationDoneRequest(response, args);
4 ✔
830

831
        let error: Error;
832
        try {
4 ✔
833
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
4 ✔
834

835
            //press the home button to ensure we're at the home screen
836
            await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
4 ✔
837

838
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
839
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
4 ✔
840

841
            //pass along the console output
842
            if (this.launchConfiguration.consoleOutput === 'full') {
4 !
UNCOV
843
                this.rokuAdapter.on('console-output', (data) => {
×
UNCOV
844
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
845
                });
846
            } else {
847
                this.rokuAdapter.on('unhandled-console-output', (data) => {
4 ✔
848
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
849
                });
850
            }
851

852
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
4 ✔
853
                const stopDebuggerAction = 'Stop Debugger';
×
UNCOV
854
                const message = `Roku device ${this.deviceLabel} is not responding and may not recover.` +
×
855
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
×
UNCOV
856
                this.logger.log(message, data);
×
UNCOV
857
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
858
                if (response === stopDebuggerAction) {
×
859
                    await this.shutdown();
×
860
                }
861
            });
862

863
            // Send chanperf events to the extension
864
            this.rokuAdapter.on('chanperf', (output) => {
4 ✔
UNCOV
865
                this.sendEvent(new ChanperfEvent(output));
×
866
            });
867

868
            //listen for a closed connection (shut down when received)
869
            this.rokuAdapter.on('close', (reason = '') => {
4 !
870
                if (reason === 'compileErrors') {
×
UNCOV
871
                    error = new Error('compileErrors');
×
872
                } else {
UNCOV
873
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
×
874
                }
875
            });
876

877
            // handle any compile errors
878
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
4 ✔
UNCOV
879
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
×
880
            });
881

882
            // close disconnect if required when the app is exited
883
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
884
            this.rokuAdapter.on('app-exit', async () => {
4 ✔
UNCOV
885
                this.resetSessionState();
×
886

UNCOV
887
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
UNCOV
888
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
UNCOV
889
                    message += ' - shutting down debug session';
×
890

UNCOV
891
                    this.logger.log('on app-exit', message);
×
892
                    this.sendEvent(new LogOutputEvent(message));
×
893
                    await this.shutdown();
×
894
                } else {
UNCOV
895
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
×
896
                    this.logger.log('[configurationDoneRequest]', message);
×
897
                    this.sendEvent(new LogOutputEvent(message));
×
898
                    this.rokuAdapter.once('connected').then(async () => {
×
UNCOV
899
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
×
900
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
×
901
                }
902
            });
903
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
904
            await this.tryProfilingConnectOnStart();
4 ✔
905

906
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
907
            //that the client has finished sending configuration). Validate breakpoints across every project
908
            //in one pass (and inject the STOPs for telnet) - validating per project would fail every other
909
            //project's breakpoints. Must finish before postfixing renames the complib files.
910
            await this.writeBreakpoints();
4 ✔
911

912
            //postfix each complib's own files now (still no zips. those are sealed after cross-project
913
            //references are rewritten).
914
            await this.postfixComponentLibraries(this.launchConfiguration.componentLibraries);
4 ✔
915

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

920
            //now zip the main project, also zip and upload installable complibs, and start the webserver for non-installed complibs.
921
            await Promise.all([
4 ✔
922
                this.zipMainProject(),
923
                this.zipServeAndInstallComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
924
            ]);
925

926
            this.sendLaunchProgress('update', 'Uploading to Roku');
4 ✔
927
            await this.publish();
4 ✔
928

929
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
930
            if (this.launchConfiguration.emitChannelPublishedEvent) {
3 !
931
                this.sendEvent(new ChannelPublishedEvent(
3 ✔
932
                    this.launchConfiguration
933
                ));
934
            }
935

936
            //tell the adapter adapter that the channel has been launched.
937
            this.sendLaunchProgress('update', 'Waiting on application');
3 ✔
938
            await this.rokuAdapter.activate();
3 ✔
939
            if (this.rokuAdapter.isDestroyed) {
3 !
UNCOV
940
                throw new Error('Debug session encountered an error');
×
941
            }
942
            if (!error) {
3 !
943
                if (this.rokuAdapter.connected) {
3 !
944
                    this.logger.info('Host connection was established before the main public process was completed');
3 ✔
945
                    this.logger.log(`deployed to Roku@${this.deviceLabel}`);
3 ✔
946
                } else {
UNCOV
947
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
UNCOV
948
                    this.rokuAdapter.on('connected', (status) => {
×
UNCOV
949
                        if (status) {
×
UNCOV
950
                            this.logger.log(`deployed to Roku@${this.deviceLabel}`);
×
951
                        }
952
                    });
953
                }
954
            } else {
955
                throw error;
×
956
            }
957

958
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
959
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
3 !
960
                //wait until the first entry breakpoint has been hit
UNCOV
961
                await this.firstRunDeferred.promise;
×
962
                //if we are at a breakpoint, continue
UNCOV
963
                await this.rokuAdapter.continue();
×
964
                //kill the app on the roku
965
                // await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
966
                //convert a hostname to an ip address
UNCOV
967
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
968
                //send the deep link http request
UNCOV
969
                await util.httpPost(deepLinkUrl);
×
970
            }
971

972
        } catch (e) {
973
            //if the message is anything other than compile errors, we want to display the error
974
            if (!(e instanceof CompileError)) {
1 !
UNCOV
975
                util.log('Encountered an issue during the publish process');
×
UNCOV
976
                util.log((e as Error)?.stack);
×
977

978
                //send any compile errors to the client
UNCOV
979
                await this.rokuAdapter?.sendErrors();
×
980

981
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
UNCOV
982
                await this.shutdown(message as string, true);
×
983
            } else {
984
                this.sendLaunchProgress('end', 'Aborted (compile error)');
1 ✔
985
            }
986
        }
987
    }
988

989
    /**
990
     * Activate all required functionality for profiling
991
     */
992
    private async initializeProfiling() {
993

994
        // Initialize PerfettoManager
995
        this.perfettoManager = new PerfettoManager({
18 ✔
996
            device: this.launchConfiguration.device,
997
            rootDir: this.launchConfiguration.rootDir,
998
            remotePort: this.launchConfiguration.remotePort,
999
            ...this.launchConfiguration.profiling?.tracing
54 ✔
1000
        });
1001

1002
        //send certain profiling events back to the client
1003
        this.perfettoManager.on('enable', (event) => {
18 ✔
UNCOV
1004
            this.sendEvent(new ProfilingEnableEvent({
×
1005
                types: event.types
1006
            }));
1007
        });
1008
        this.perfettoManager.on('start', (event) => {
18 ✔
1009
            this.sendEvent(new ProfilingStartEvent({
×
1010
                type: event.type
1011
            }));
1012
        });
1013
        this.perfettoManager.on('stop', (event) => {
18 ✔
1014
            this.sendEvent(new ProfilingStopEvent({
×
1015
                type: event.type,
1016
                result: event.result
1017
            }));
1018
        });
1019
        this.perfettoManager.on('error', (event) => {
18 ✔
UNCOV
1020
            this.sendEvent(new ProfilingErrorEvent({
×
1021
                error: event.error
1022
            }));
1023
        });
1024

1025
        //tracing is explicitly enabled. Turn it on
1026
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
18 ✔
1027
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
2 ✔
1028
            try {
2 ✔
1029
                await this.perfettoManager?.enableTracing?.();
2 !
1030
            } catch (e) {
1031
                this.logger.error('Failed to enable perfetto tracing', e);
1 ✔
1032
            }
1033

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

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

1046
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
1047
        } else {
1048
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
12 ✔
1049
        }
1050
    }
1051

1052
    /**
1053
     * If profiling was marked "connectOnStart", try connecting right away
1054
     */
1055
    private async tryProfilingConnectOnStart() {
1056
        //tracing is off for this session, so there is nothing to connect to
1057
        if (!this.launchConfiguration.profiling?.tracing?.enable) {
10 ✔
1058
            return;
7 ✔
1059
        }
1060
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
3 !
1061
            try {
2 ✔
1062
                await this.perfettoManager?.startTracing?.();
2 !
1063
            } catch (e) {
1064
                this.logger.error('Failed to start perfetto tracing on start', e);
1 ✔
1065
            }
1066
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
1 !
1067
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
1 !
1068
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1 ✔
1069
            this.logger.warn(message);
1 ✔
1070
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
1 ✔
1071
        }
1072
    }
1073

1074
    /**
1075
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
1076
     */
1077
    private resetSessionState() {
1078
        // launchRequest gets invoked by our restart session flow.
1079
        // We need to clear/reset some state to avoid issues.
1080
        this.entryBreakpointWasHandled = false;
11 ✔
1081
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
1082
        //re-stages the project
1083
        this.breakpointManager.reset();
11 ✔
1084
    }
1085

1086
    /**
1087
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
1088
     */
1089
    public async initRendezvousTracking() {
1090
        const timeout = 5000;
3 ✔
1091
        let initCompleted = false;
3 ✔
1092
        await Promise.race([
3 ✔
1093
            util.sleep(timeout),
1094
            this._initRendezvousTracking().finally(() => {
1095
                initCompleted = true;
3 ✔
1096
            })
1097
        ]);
1098

1099
        if (initCompleted === false) {
3 ✔
1100
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
1 ✔
UNCOV
1101
                this.logger.error('Error showing popup message', { error });
×
1102
            });
1103
        }
1104
    }
1105

1106
    private async _initRendezvousTracking() {
1107
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3 ✔
1108

1109
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
1110
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
3 ✔
UNCOV
1111
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
1112
        });
1113

1114
        // Send rendezvous events to the debug protocol client
1115
        this.rendezvousTracker.on('rendezvous', (output) => {
3 ✔
1116
            this.sendEvent(new RendezvousEvent(output));
1 ✔
1117
        });
1118

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

1122
        //if rendezvous tracking is enabled, then enable it on the device
1123
        if (this.launchConfiguration.rendezvousTracking !== false) {
3 ✔
1124
            // start ECP rendezvous tracking (if possible)
1125
            await this.rendezvousTracker.activate();
2 ✔
1126
        }
1127
    }
1128

1129
    /**
1130
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1131
     */
1132
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1133
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1134
        for (let diagnostic of diagnostics) {
2 ✔
1135
            diagnostic.source = diagnosticSource;
2 ✔
1136
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2 ✔
1137
            if (sourceLocation) {
2 ✔
1138
                diagnostic.path = sourceLocation.filePath;
1 ✔
1139
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1 ✔
1140
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1 ✔
1141
            } else {
1142
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1143
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1 ✔
1144
            }
1145
        }
1146

1147
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1148
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
2 ✔
1149
        if (this.compileError) {
2 ✔
1150
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1 ✔
1151
            this.sendEvent(new StoppedEvent(
1 ✔
1152
                StoppedEventReason.exception,
1153
                this.COMPILE_ERROR_THREAD_ID,
1154
                `CompileError: ${this.compileError.message}`
1155
            ));
1156
        }
1157

1158
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2 ✔
1159
    }
1160

1161
    private publishTimeout = 60_000;
229 ✔
1162

1163
    private async publish() {
1164
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
3 ✔
1165
        let packageIsPublished = false;
3 ✔
1166

1167
        //delete any currently installed dev channel (if enabled to do so)
1168
        try {
3 ✔
1169
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
3 !
UNCOV
1170
                await this.rokuDeploy.deleteDevChannel({
×
1171
                    device: this.launchConfiguration.device,
1172
                    password: this.launchConfiguration.password,
1173
                    username: this.launchConfiguration.username,
1174
                    packagePort: this.launchConfiguration.packagePort
1175
                });
1176
            }
1177
        } catch (e) {
UNCOV
1178
            const statusCode = e?.results?.response?.statusCode;
×
UNCOV
1179
            const message = e.message as string;
×
UNCOV
1180
            if (statusCode === 401) {
×
UNCOV
1181
                await this.shutdown(message, true);
×
UNCOV
1182
                throw e;
×
1183
            }
1184
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1185
        }
1186

1187
        const isConnected = this.rokuAdapter.once('app-ready');
3 ✔
1188
        const options: SideloadOptions = {
3 ✔
1189
            device: this.launchConfiguration.device,
1190
            password: this.launchConfiguration.password,
1191
            username: this.launchConfiguration.username,
1192
            packagePort: this.launchConfiguration.packagePort,
1193
            ecpPort: this.launchConfiguration.remotePort,
1194
            //sideload the zip that was already built from the staging folder (or supplied via packagePath)
1195
            zip: this.launchConfiguration.packagePath ?? util.getOutputZipPath({ outDir: this.launchConfiguration.outDir }),
9 ✔
1196
            // enable the debug protocol if true
1197
            remoteDebug: this.enableDebugProtocol,
1198
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1199
            remoteDebugConnectEarly: false,
1200
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1201
            failOnCompileError: true,
1202
            //deleting the dev channel (when enabled) was already handled at the start of this function
1203
            deleteDevChannel: false,
1204
            //the device was already sent to the home screen during configurationDone
1205
            close: false,
1206
            //pass any upload form overrides the client may have configured
1207
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1208
        };
1209

1210
        //publish the package to the target Roku
1211
        const publishPromise = this.rokuDeploy.sideload(options).then(() => {
3 ✔
1212
            packageIsPublished = true;
3 ✔
1213
        }).catch(async (e) => {
UNCOV
1214
            const statusCode = e?.results?.response?.statusCode;
×
UNCOV
1215
            const message = e.message as string;
×
UNCOV
1216
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
×
UNCOV
1217
                await this.shutdown(message, true);
×
UNCOV
1218
                throw e;
×
1219
            }
1220
            this.logger.error(e);
×
1221
        });
1222

1223
        await publishPromise;
3 ✔
1224

1225
        uploadingEnd();
3 ✔
1226

1227
        //the channel has been deployed. Wait for the adapter to finish connecting.
1228
        //if it hasn't connected after 60 seconds, abort the launch.
1229
        let didTimeOut = false;
3 ✔
1230
        await Promise.race([
3 ✔
1231
            isConnected,
1232
            util.sleep(this.publishTimeout).then(() => {
1233
                didTimeOut = true;
3 ✔
1234
            })
1235
        ]);
1236
        this.logger.log('Finished racing promises');
3 ✔
1237
        if (didTimeOut) {
3 ✔
1238
            this.logger.warn('Timed out waiting for roku to connect');
1 ✔
1239
        }
1240
        //if the adapter is still not connected, then it will probably never connect. Abort.
1241
        if (packageIsPublished && !this.rokuAdapter.connected) {
3 ✔
1242
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1 ✔
1243
        }
1244
    }
1245

1246
    private pendingSendLogPromise = Promise.resolve();
229 ✔
1247

1248
    /**
1249
     * Send log output to the "client" (i.e. vscode)
1250
     * @param logOutput
1251
     */
1252
    private sendLogOutput(logOutput: string) {
1253
        if (this.isCrashed) {
38 !
UNCOV
1254
            return Promise.resolve();
×
1255
        }
1256
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38 ✔
1257

1258
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38 ✔
1259
            logOutput = await this.convertBacktracePaths(logOutput);
38 ✔
1260

1261
            const lines = logOutput.split(/\r?\n/g);
38 ✔
1262
            for (let i = 0; i < lines.length; i++) {
38 ✔
1263
                let line = lines[i];
228 ✔
1264
                if (i < lines.length - 1) {
228 ✔
1265
                    line += '\n';
190 ✔
1266
                }
1267

1268
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
228 ✔
1269
                    let potentialPaths = this.getPotentialPkgPaths(line);
91 ✔
1270
                    for (let potentialPath of potentialPaths) {
91 ✔
1271
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
28 ✔
1272
                        if (originalLocation) {
28 ✔
1273
                            let replacement: string;
1274
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
26 ✔
1275
                            if (replacement !== originalLocation.filePath) {
26 ✔
1276
                                if (this.isWindowsPlatform) {
6 ✔
1277
                                    replacement = `vscode://file/${replacement}`;
3 ✔
1278
                                } else {
1279
                                    replacement = `file://${replacement}`;
3 ✔
1280
                                }
1281
                            }
1282
                            replacement += `:${originalLocation.lineNumber}`;
26 ✔
1283
                            if (potentialPath.columnNumber !== undefined) {
26 ✔
1284
                                replacement += `:${originalLocation.columnIndex + 1}`;
10 ✔
1285
                            }
1286

1287
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26 ✔
1288
                        }
1289
                    }
1290
                }
1291
                this.sendEvent(new OutputEvent(line, 'stdout'));
228 ✔
1292
                this.sendEvent(new LogOutputEvent(line));
228 ✔
1293
            }
1294
        });
1295
        return this.pendingSendLogPromise;
38 ✔
1296
    }
1297

1298
    /**
1299
     * Extracts potential package paths from a given line of text.
1300
     *
1301
     * This method uses a regular expression to find matches in the provided line
1302
     * and returns an array of objects containing details about each match.
1303
     *
1304
     * @param input - The line of text to search for potential package paths.
1305
     * @returns An array of objects, each containing:
1306
     *   - `fullMatch`: The full matched string.
1307
     *   - `path`: The extracted path from the match.
1308
     *   - `lineNumber`: The line number extracted from the match.
1309
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1310
     */
1311
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1312
        // https://regex101.com/r/ixpQiq/1
1313
        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 ✔
1314
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91 ✔
1315
        if (matches) {
91 !
1316
            for (let match of matches) {
91 ✔
1317
                let fullMatch = match[0];
28 ✔
1318
                let path = match[1];
28 ✔
1319
                let lineNumber = parseInt(match[2] ?? match[4]);
28 ✔
1320
                let columnNumber = parseInt(match[3] ?? match[5]);
28 ✔
1321
                if (isNaN(columnNumber)) {
28 ✔
1322
                    columnNumber = undefined;
17 ✔
1323
                }
1324
                paths.push({
28 ✔
1325
                    fullMatch: fullMatch,
1326
                    path: path,
1327
                    lineNumber: lineNumber,
1328
                    columnNumber: columnNumber
1329
                });
1330
            }
1331
        }
1332
        return paths;
91 ✔
1333
    }
1334

1335
    /**
1336
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1337
     */
1338
    private async convertBacktracePaths(input: string) {
1339
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38 ✔
1340
            return input;
8 ✔
1341
        }
1342
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1343
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1344

1345
        // https://regex101.com/r/y1koaV/2
1346
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
30 ✔
1347
        let matches = [];
30 ✔
1348
        let match = deviceBacktraceObjectRegex.exec(input);
30 ✔
1349
        while (match) {
30 ✔
1350
            matches.push(match);
5 ✔
1351
            match = deviceBacktraceObjectRegex.exec(input);
5 ✔
1352
        }
1353

1354
        if (matches) {
30 !
1355
            for (let match of matches) {
30 ✔
1356
                let fullMatch = match[0] as string;
5 ✔
1357
                let filePath = match[1] as string;
5 ✔
1358
                let fullLineNumber = match[2] as string;
5 ✔
1359
                let lineNumber = parseInt(match[3] as string);
5 ✔
1360
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
5 ✔
1361
                if (originalLocation) {
5 ✔
1362
                    let fileReplacement: string;
1363
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
4 ✔
1364
                    if (fileReplacement !== originalLocation.filePath) {
4 ✔
1365
                        if (this.isWindowsPlatform) {
2 ✔
1366
                            fileReplacement = `vscode://file/${fileReplacement}`;
1 ✔
1367
                        } else {
1368
                            fileReplacement = `file://${fileReplacement}`;
1 ✔
1369
                        }
1370
                    }
1371
                    fileReplacement += `:${originalLocation.lineNumber}`;
4 ✔
1372

1373
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4 ✔
1374

1375
                    // 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
1376
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4 ✔
1377
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4 ✔
1378
                    input = input.replaceAll(fullMatch, completeReplacement);
4 ✔
1379
                }
1380

1381
            }
1382
        }
1383

1384
        return input;
30 ✔
1385
    }
1386

1387
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1388
        if (commands) {
1 !
UNCOV
1389
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.device, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1390

UNCOV
1391
            try {
×
UNCOV
1392
                await connection.connect();
×
UNCOV
1393
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
×
1394
                    let response: SceneGraphCommandResponse;
UNCOV
1395
                    switch (command) {
×
1396
                        case 'chanperf':
1397
                            util.log('Enabling Chanperf Tracking');
×
1398
                            response = await connection.chanperf({ interval: 1 });
×
UNCOV
1399
                            if (!response.error) {
×
1400
                                util.log(response.result.rawResponse);
×
1401
                            }
1402
                            break;
×
1403

1404
                        case 'fpsdisplay':
1405
                            util.log('Enabling FPS Display');
×
UNCOV
1406
                            response = await connection.fpsDisplay('on');
×
1407
                            if (!response.error) {
×
UNCOV
1408
                                util.log(response.result.data as string);
×
1409
                            }
1410
                            break;
×
1411

1412
                        case 'logrendezvous':
1413
                            util.log('Enabling Rendezvous Logging:');
×
UNCOV
1414
                            response = await connection.logrendezvous('on');
×
1415
                            if (!response.error) {
×
UNCOV
1416
                                util.log(response.result.rawResponse);
×
1417
                            }
1418
                            break;
×
1419

1420
                        default:
1421
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
×
UNCOV
1422
                            response = await connection.exec(command);
×
1423
                            if (!response.error) {
×
UNCOV
1424
                                util.log(response.result.rawResponse);
×
1425
                            }
1426
                            break;
×
1427
                    }
1428
                }
1429
                await connection.end();
×
1430
            } catch (error) {
1431
                util.log(`Error connecting to port 8080: ${error.message}`);
×
1432
            }
1433
        }
1434
    }
1435

1436
    /**
1437
     * Stage, insert breakpoints, and package the main project
1438
     */
1439
    public async prepareMainProject() {
1440
        //add the main project
1441
        this.projectManager.mainProject = new Project({
2 ✔
1442
            rootDir: this.launchConfiguration.rootDir,
1443
            files: this.launchConfiguration.files,
1444
            outDir: this.launchConfiguration.outDir,
1445
            sourceDirs: this.launchConfiguration.sourceDirs,
1446
            bsConst: this.launchConfiguration.bsConst,
1447
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1448
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1449
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1450
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1451
            stagingDir: this.launchConfiguration.stagingDir,
1452
            packagePath: this.launchConfiguration.packagePath,
1453
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1454
        });
1455

1456
        util.log('Moving selected files to staging area');
2 ✔
1457
        await this.projectManager.mainProject.stage();
2 ✔
1458

1459
        //add the entry breakpoint if stopOnEntry is true
1460
        await this.handleEntryBreakpoint();
2 ✔
1461
    }
1462

1463
    /**
1464
     * Validate every breakpoint against the staged projects and (telnet only) write STOP statements into the
1465
     * staged .brs files. Runs after the DAP `InitializedEvent` so client-side `setBreakpoints` requests have
1466
     * landed before any STOPs are written. Validates the main project and every component library in ONE pass:
1467
     * a breakpoint is only failed when it can't be placed in any project, so validating per project would fail
1468
     * every other project's breakpoints. Must complete before `postfixComponentLibraries` renames the complib
1469
     * files so telnet STOPs are written to the original file names.
1470
     */
1471
    private async writeBreakpoints() {
1472
        //add breakpoint lines to source files and then publish
1473
        util.log('Adding stop statements for active breakpoints');
11 ✔
1474

1475
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1476
        await this.breakpointManager.validateAndWriteBreakpointsForProjects(this.projectManager.getAllProjects());
11 ✔
1477
    }
1478

1479
    /**
1480
     * Create the main project's zip package (or run the configured packageTask). Must run AFTER
1481
     * `applyLibraryReferencePostfixes` so any rewritten `Library` statements are included in the zip.
1482
     */
1483
    private async zipMainProject() {
1484
        if (this.launchConfiguration.packageTask) {
2 ✔
1485
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1 ✔
1486
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1 ✔
1487

1488
            const packagePath = this.launchConfiguration.packagePath ?? util.getOutputZipPath({ outDir: this.launchConfiguration.outDir });
1 !
1489

1490
            if (!fsExtra.pathExistsSync(packagePath)) {
1 !
UNCOV
1491
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
×
1492
            }
1493
        } else {
1494
            //create zip package from staging folder
1495
            util.log('Creating zip archive from project sources');
1 ✔
1496
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1 ✔
1497
        }
1498
    }
1499

1500
    /**
1501
     * Accepts custom events and requests from the extension
1502
     * @param command name of the command to execute
1503
     */
1504
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
UNCOV
1505
        if (command === 'rendezvous.clearHistory') {
×
UNCOV
1506
            this.rokuAdapter.clearRendezvousHistory();
×
UNCOV
1507
        } else if (command === 'chanperf.clearHistory') {
×
UNCOV
1508
            this.rokuAdapter.clearChanperfHistory();
×
1509

1510
        } else if (command === 'customRequestEventResponse') {
×
1511
            this.emit('customRequestEventResponse', args);
×
1512

1513
        } else if (command === 'popupMessageEventResponse') {
×
UNCOV
1514
            this.emit('popupMessageEventResponse', args);
×
1515

1516
        } else if (command === 'captureHeapSnapshot') {
×
UNCOV
1517
            this.perfettoManager?.captureHeapSnapshot?.().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
×
1518

1519
        } else if (command === 'startPerfettoTracing') {
×
UNCOV
1520
            try {
×
1521
                await this.perfettoManager?.startTracing?.();
×
1522
            } catch (e) {
UNCOV
1523
                response.success = false;
×
1524
                response.body = { message: e?.message || String(e) };
×
1525
            }
1526

UNCOV
1527
        } else if (command === 'stopPerfettoTracing') {
×
1528
            try {
×
1529
                await this.perfettoManager?.stopTracing?.();
×
1530
            } catch (e) {
UNCOV
1531
                response.success = false;
×
1532
                response.body = { message: e?.message || String(e) };
×
1533
            }
1534

1535
        }
1536
        this.sendResponse(response);
×
1537
    }
1538

1539
    /**
1540
     * Stores the path to the staging folder for each component library
1541
     */
1542
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1543
        if (!componentLibraries || componentLibraries.length === 0) {
11 ✔
1544
            return;
2 ✔
1545
        }
1546
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9 ✔
1547
        //make sure this folder exists (and is empty)
1548
        await fsExtra.ensureDir(componentLibrariesOutDir);
9 ✔
1549
        await fsExtra.emptyDir(componentLibrariesOutDir);
9 ✔
1550

1551
        //create a ComponentLibraryProject for each component library
1552
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9 ✔
1553
            let componentLibrary = componentLibraries[libraryIndex];
19 ✔
1554

1555
            this.projectManager.componentLibraryProjects.push(
19 ✔
1556
                new ComponentLibraryProject({
1557
                    rootDir: componentLibrary.rootDir,
1558
                    files: componentLibrary.files,
1559
                    outDir: componentLibrariesOutDir,
1560
                    outFile: componentLibrary.outFile,
1561
                    sourceDirs: componentLibrary.sourceDirs,
1562
                    bsConst: componentLibrary.bsConst,
1563
                    install: componentLibrary.install,
1564
                    enablePostfix: componentLibrary.enablePostfix,
1565
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1566
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1567
                    libraryIndex: libraryIndex,
1568
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1569
                })
1570
            );
1571
        }
1572

1573
        //stage all of the libraries in parallel
1574
        await Promise.all(
9 ✔
1575
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19 ✔
1576
        );
1577
    }
1578

1579
    /**
1580
     * Postfix each staged complib's own files (rename its `.brs` files with the `__lib<index>` postfix and
1581
     * rewrite its `uri=` references). Runs in `configurationDoneRequest` AFTER `writeBreakpoints` so any
1582
     * telnet STOPs have already been written to the original file names. Kept separate from zipping so the
1583
     * cross-project `Library` reference rewrite can run after EVERY project is postfixed but before any zip
1584
     * is sealed.
1585
     */
1586
    protected async postfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1587
        if (!componentLibraries || componentLibraries.length === 0) {
10 ✔
1588
            return;
2 ✔
1589
        }
1590

1591
        //postfix each complib's own files in parallel
1592
        await Promise.all(
8 ✔
1593
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.postfixFiles())
18 ✔
1594
        );
1595
    }
1596

1597
    /**
1598
     * Seal every component library's zip, install the installable ones (in order), and start static file
1599
     * hosting. Must run AFTER `applyLibraryReferencePostfixes` so each zip contains the rewritten `Library`
1600
     * statements.
1601
     */
1602
    protected async zipServeAndInstallComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1603
        if (!componentLibraries || componentLibraries.length === 0) {
10 ✔
1604
            return;
2 ✔
1605
        }
1606
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8 ✔
1607

1608
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9 ✔
1609
        if (needToDeleteComplibs) {
8 ✔
1610
            //deleteAllComponentLibraries pauses compile-error reporting (to swallow deletion-induced errors) and
1611
            //leaves it paused. Now that deletion is done and we're about to put libraries back on the device, settle
1612
            //(draining any lingering deletion output) and resume reporting so real install errors are surfaced.
1613
            await this.deleteAllComponentLibraries();
7 ✔
1614
        }
1615

1616
        //zip each complib in parallel
1617
        const packagePromises = this.projectManager.componentLibraryProjects.map(
8 ✔
1618
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
18 ✔
1619
        );
1620

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

1628
            if (compLibProject.install === true) {
18 ✔
1629
                //wait for this complib to finish being packaged
1630
                await packagePromises[i];
12 ✔
1631

1632
                if (componentLibraries[i].packageTask) {
12 ✔
1633
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2 ✔
1634
                }
1635

1636
                const options: SideloadOptions = {
12 ✔
1637
                    device: this.launchConfiguration.device,
1638
                    password: this.launchConfiguration.password,
1639
                    username: this.launchConfiguration.username || 'rokudev',
24 ✔
1640
                    packagePort: this.launchConfiguration.packagePort,
1641
                    ecpPort: this.launchConfiguration.remotePort,
1642
                    zip: componentLibraries[i].packagePath ?? util.getOutputZipPath({ outDir: compLibProject.outDir, outFile: compLibProject.outFile }),
36 ✔
1643
                    failOnCompileError: true,
1644
                    appType: 'dcl',
1645
                    //installing a component library should never close or delete the sideloaded channel
1646
                    close: false,
1647
                    deleteDevChannel: false,
1648
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
22 ✔
1649
                };
1650

1651
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
12 ✔
1652
                try {
12 ✔
1653
                    await rokuDeploy.sideload(options);
12 ✔
1654
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
11 ✔
1655
                } catch (error) {
1656
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
1657
                    //here means later libraries and the main app would reference a library that isn't on the device
1658
                    this.logger.error(`Error installing component library ${i} (${compLibProject.outFile})`, error);
1 ✔
1659
                    throw error;
1 ✔
1660
                }
1661
            }
1662
        }
1663

1664
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
7 ✔
UNCOV
1665
            util.log(message);
×
1666
        });
1667

1668
        //wait for all complib packaging to finish and the file hosting to start
1669
        await Promise.all([
7 ✔
1670
            ...packagePromises,
1671
            hostingPromise
1672
        ]);
1673
    }
1674

1675
    /**
1676
     * Delete every component library installed on the device.
1677
     *
1678
     * A complib can only depend on complibs the user declared BEFORE it, so the user's configured order is already
1679
     * dependency order. Deleting in the REVERSE of that order therefore deletes each complib before the ones it
1680
     * depends on, avoiding compile errors entirely. We use that reverse order as the delete priority.
1681
     *
1682
     * Anything still installed that the user did NOT configure (e.g. leftovers from a previous session) has no known
1683
     * order, so we fall back to compile-error tolerance for those: deleting a complib while another still references
1684
     * it fails with a compile error, which we ignore and retry later (after its dependent is gone). We finish when
1685
     * every complib is gone, and fail if the only complibs left are ones that can never be deleted.
1686
     */
1687
    private async deleteAllComponentLibraries() {
1688
        const deviceOptions = {
16 ✔
1689
            device: this.launchConfiguration.device,
1690
            password: this.launchConfiguration.password,
1691
            username: this.launchConfiguration.username || 'rokudev'
32 ✔
1692
        };
1693

1694
        //The user's configured complibs, in REVERSE declaration order (dependents before their dependencies). A
1695
        //complib's position here is its delete priority; complibs the user didn't configure aren't in this list.
1696
        const deletePriority = this.projectManager.componentLibraryProjects
16 ✔
1697
            .map(complib => complib.outFile)
28 ✔
1698
            .reverse();
1699
        //sort key for a complib: its index in `deletePriority`, or Infinity (delete last) if it wasn't configured
1700
        const priorityOf = (fileName: string) => {
16 ✔
1701
            const index = deletePriority.indexOf(fileName);
24 ✔
1702
            return index === -1 ? Infinity : index;
24 ✔
1703
        };
1704

1705
        const maxAttempts = 5;
16 ✔
1706
        //how many times we've tried to delete each complib, and the ones we've given up on after maxAttempts
1707
        const attempts = new Map<string, number>();
16 ✔
1708
        const failed = new Set<string>();
16 ✔
1709

1710
        while (true) {
16 ✔
1711
            //re-fetch the installed complibs after each iteration: deleting one complib can cascade-delete others, so never delete a stale entry
1712
            const installed = (await rokuDeploy.listSideloadedPlugins(deviceOptions)).filter(x => x.appType === 'dcl');
40 ✔
1713

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

1716
            //of the complibs we haven't given up on, pick the next to delete: fewest attempts first (spreads retries
1717
            //evenly so a dependent gets deleted before we circle back to a complib that previously failed), and among
1718
            //equal attempts, follow the reverse-configured delete priority (dependents before dependencies).
1719
            const candidates = installed.filter(complib => !failed.has(complib.archiveFileName));
40 ✔
1720
            const next = candidates.sort((a, b) =>
39 ✔
1721
                attemptCount(a) - attemptCount(b) ||
15 ✔
1722
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
1723
            )[0];
1724

1725
            //nothing left to try — done if the device is clear, otherwise hard-fail with whatever remains
1726
            if (!next) {
39 ✔
1727
                if (installed.length > 0) {
15 ✔
1728
                    throw new Error(`Failed to delete existing component libraries on device; ${installed.length} still installed: ${installed.map(x => x.archiveFileName).join(', ')}`);
1 ✔
1729
                }
1730
                return;
14 ✔
1731
            }
1732

1733
            const fileName = next.archiveFileName;
24 ✔
1734
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
24 ✔
1735
            try {
24 ✔
1736
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
24 ✔
1737
            } catch (error) {
1738
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
1739
                if (!this.isComponentLibraryDependencyCompileError(error)) {
9 ✔
1740
                    throw error;
1 ✔
1741
                }
1742
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
1743
                //STILL have succeeded (the device reports both a compile error and 'Delete Succeeded'). If it actually
1744
                //deleted, treat it as done. Only if it did NOT succeed do we defer and retry it later.
1745
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
8 !
UNCOV
1746
                    this.logger.trace(`Deleted '${fileName}' (device reported a compile error but the delete succeeded)`);
×
1747
                } else {
1748
                    //another not-yet-deleted complib still depends on this one. Give up on it once it's out of
1749
                    //attempts; otherwise leave it pending so we retry after its dependents are gone.
1750
                    if (attempts.get(fileName) >= maxAttempts) {
8 ✔
1751
                        failed.add(fileName);
1 ✔
1752
                    }
1753
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
8 ✔
1754
                }
1755
            }
1756

1757
            //the Roku doesn't appreciate back-to-back deletes; give it a moment between requests
1758
            await util.sleep(10);
23 ✔
1759
        }
1760
    }
1761

1762
    /**
1763
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
1764
     * installed complib still references the one we tried to delete)?
1765
     */
1766
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1767
        const message = `${error?.message ?? ''}`;
9 !
1768
        return /compile error|compilation failed/i.test(message);
9 ✔
1769
    }
1770

1771
    /**
1772
     * Did the component-library delete actually succeed, even though the device also reported a compile error?
1773
     * When we delete a complib that a dependent still references, the device reports BOTH a compile error AND a
1774
     * `Delete Succeeded` message - meaning the complib really was removed. roku-deploy attaches the parsed device
1775
     * messages (`{ errors, infos, successes }`) to the thrown error as `rokuMessages`; we look for the success there.
1776
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
1777
     */
1778
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
1779
        const successes: string[] = error?.rokuMessages?.successes ?? [];
8 !
1780
        return successes.some(message => /delete succeeded/i.test(message));
8 ✔
1781
    }
1782

1783
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
UNCOV
1784
        this.logger.log('sourceRequest');
×
UNCOV
1785
        let old = this.sendResponse;
×
UNCOV
1786
        this.sendResponse = function sendResponse(...args) {
×
UNCOV
1787
            old.apply(this, args);
×
UNCOV
1788
            this.sendResponse = old;
×
1789
        };
1790
        super.sourceRequest(response, args);
×
1791
    }
1792

1793
    /**
1794
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1795
     */
1796
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1797
        this.logger.log('setBreakpointsRequest', args);
6 ✔
1798
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6 ✔
1799
        //sort the breakpoints
1800
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6 ✔
1801

1802
        response.body = {
6 ✔
1803
            breakpoints: sortedAndFilteredBreakpoints
1804
        };
1805
        this.sendResponse(response);
6 ✔
1806

1807
        //ensure we've staged all the files
1808
        await this.stagingDefered.promise;
6 ✔
1809

1810
        await this.rokuAdapter?.syncBreakpoints();
6 !
1811
    }
1812

1813
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
UNCOV
1814
        this.logger.log('exceptionInfoRequest');
×
1815
    }
1816

1817
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1818
        this.logger.log('threadsRequest');
4 ✔
1819

1820
        let threads = [];
4 ✔
1821

1822
        //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
1823
        if (this.compileError) {
4 !
UNCOV
1824
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1825
        } else {
1826
            //wait for the roku adapter to load
1827
            await this.getRokuAdapter();
4 ✔
1828

1829
            //only send the threads request if we are at the debugger prompt
1830
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4 ✔
1831
                let rokuThreads = await this.rokuAdapter.getThreads();
3 ✔
1832

1833
                for (let thread of rokuThreads) {
3 ✔
1834
                    const threadName = this.getThreadName(thread as AdapterThread);
4 ✔
1835
                    threads.push(
4 ✔
1836
                        new Thread(thread.threadId, threadName)
1837
                    );
1838
                }
1839

1840
                if (threads.length === 0) {
3 !
UNCOV
1841
                    threads = [{
×
1842
                        id: 1001,
1843
                        name: 'unable to retrieve threads: not stopped',
1844
                        isFake: true
1845
                    }];
1846
                }
1847

1848
            } else {
1849
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1 ✔
1850
            }
1851

1852
        }
1853

1854
        response.body = {
4 ✔
1855
            threads: threads
1856
        };
1857

1858
        this.sendResponse(response);
4 ✔
1859
    }
1860

1861
    /**
1862
     * Get the thread name to display in the UI based on the thread info we have.
1863
     * This is what displays in the `call stack` region in vscode
1864
     * @param thread
1865
     * @returns
1866
     */
1867
    private getThreadName(thread: AdapterThread) {
1868
        let threadName = '';
24 ✔
1869
        if (thread.type || thread.name || thread.osThreadId) {
24 ✔
1870
            //build the name from only the parts that are present, so missing values don't leak into the name
1871
            const parts: string[] = [];
13 ✔
1872
            if (thread.type) {
13 ✔
1873
                parts.push(`[${thread.type}]`);
10 ✔
1874
            }
1875
            if (thread.name) {
13 ✔
1876
                parts.push(thread.name);
9 ✔
1877
            }
1878
            if (thread.osThreadId) {
13 ✔
1879
                parts.push(thread.osThreadId);
9 ✔
1880
            }
1881
            threadName = parts.join(' ');
13 ✔
1882
        }
1883
        //remove any extraneous whitespace to deal with missing values
1884
        threadName = threadName.replace(/\s+/g, ' ').trim();
24 ✔
1885

1886
        if (threadName === '') {
24 ✔
1887
            threadName = `Thread ${thread.threadId}`;
11 ✔
1888
        }
1889

1890
        if (thread.isDetached) {
24 ✔
1891
            threadName += ' [detached]';
4 ✔
1892
        }
1893

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

1897
        return threadName;
24 ✔
1898
    }
1899

1900
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1901
        try {
3 ✔
1902
            this.logger.log('stackTraceRequest');
3 ✔
1903
            let frames: DebugProtocol.StackFrame[] = [];
3 ✔
1904

1905
            //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
1906
            if (this.compileError) {
3 !
UNCOV
1907
                frames.push(new StackFrame(
×
1908
                    0,
1909
                    'Compile Error',
1910
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1911
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1912
                    this.toClientLine(this.compileError.range.start.line),
1913
                    this.toClientColumn(this.compileError.range.start.character)
1914
                ));
1915
            } else if (args.threadId === 1001) {
3 !
UNCOV
1916
                frames.push(new StackFrame(
×
1917
                    0,
1918
                    'ERROR: threads would not stop',
1919
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1920
                    this.toClientLine(0),
1921
                    this.toClientColumn(0)
1922
                ));
UNCOV
1923
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
UNCOV
1924
                    this.logger.error('Error showing popup message', { error });
×
1925
                });
1926
            } else {
1927
                //ensure the rokuAdapter is loaded
1928
                await this.getRokuAdapter();
3 ✔
1929

1930
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3 !
1931
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3 ✔
1932
                    if (stackTrace.length === 0) {
3 ✔
1933
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1934
                        // the thread without letting the user navigate to a source location
1935
                        const frame = new StackFrame(0, '[unavailable]');
2 ✔
1936
                        frame.presentationHint = 'label';
2 ✔
1937
                        frames.push(frame);
2 ✔
1938
                    } else {
1939
                        for (let debugFrame of stackTrace) {
1 ✔
1940
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3 ✔
1941

1942
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1943
                            //load the contents of the file and get the correct casing for the function identifier
1944
                            try {
3 ✔
1945
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3 !
1946
                                if (functionName) {
3 !
1947

1948
                                    //search for original function name if this is an anonymous function.
1949
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1950
                                    if (functionName.startsWith('$')) {
3 !
UNCOV
1951
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1952
                                            sourceLocation.filePath,
1953
                                            sourceLocation.lineNumber - 1,
1954
                                            functionName
1955
                                        );
1956
                                    }
1957
                                    debugFrame.functionIdentifier = functionName;
3 ✔
1958
                                }
1959
                            } catch (error) {
UNCOV
1960
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1961
                            }
1962
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3 !
1963

1964
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3 ✔
1965
                                debugFrame.frameId,
1966
                                `${debugFrame.functionIdentifier}`,
1967
                                new Source(path.basename(filePath), filePath),
1968
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1969
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18 !
1970
                                this.toClientColumn(0)
1971
                            );
1972
                            if (!sourceLocation) {
3 !
UNCOV
1973
                                frame.presentationHint = 'subtle';
×
1974
                            }
1975
                            frames.push(frame);
3 ✔
1976
                        }
1977
                    }
1978
                } else {
UNCOV
1979
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1980
                }
1981
            }
1982
            response.body = {
3 ✔
1983
                stackFrames: frames,
1984
                totalFrames: frames.length
1985
            };
1986
            this.sendResponse(response);
3 ✔
1987
        } catch (error) {
UNCOV
1988
            this.logger.error('Error getting stacktrace', { error, args });
×
1989
        }
1990
    }
1991

1992
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1993
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1 ✔
1994
        logger.info('begin', { args });
1 ✔
1995
        try {
1 ✔
1996
            const scopes = new Array<DebugProtocol.Scope>();
1 ✔
1997

1998
            // create the locals scope
1999
            let v = this.getOrCreateLocalsScope(args.frameId);
1 ✔
2000

2001
            let localScope: DebugProtocol.Scope = {
1 ✔
2002
                name: 'Local',
2003
                variablesReference: v.variablesReference,
2004
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
2005
                expensive: this.launchConfiguration.deferScopeLoading,
2006
                presentationHint: 'locals'
2007
            };
2008

2009
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1 ✔
UNCOV
2010
            if (frame) {
×
UNCOV
2011
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
2012

UNCOV
2013
                if (scopeRange) {
×
UNCOV
2014
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
2015
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
2016
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
UNCOV
2017
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
2018
                }
2019
            }
2020

2021
            scopes.push(localScope);
×
2022

2023
            // create the registry scope
UNCOV
2024
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
UNCOV
2025
            scopes.push(<DebugProtocol.Scope>{
×
2026
                name: 'Registry',
2027
                variablesReference: registryRefId,
2028
                expensive: true
2029
            });
2030

UNCOV
2031
            this.variables[registryRefId] = {
×
2032
                variablesReference: registryRefId,
2033
                name: 'Registry',
2034
                value: '',
2035
                type: '$$Registry',
2036
                isScope: true,
2037
                childVariables: []
2038
            };
2039

UNCOV
2040
            response.body = {
×
2041
                scopes: scopes
2042
            };
UNCOV
2043
            logger.debug('send response', { response });
×
UNCOV
2044
            this.sendResponse(response);
×
2045
            logger.info('end');
×
2046
        } catch (error) {
2047
            logger.error('Error getting scopes', { error, args });
1 ✔
2048
        }
2049
    }
2050

2051
    /**
2052
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
2053
     * The child variables are filled in lazily by `populateScopeVariables`.
2054
     */
2055
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
2056
        const refId = this.getEvaluateRefId('$$locals', frameId);
5 ✔
2057
        if (!this.variables[refId]) {
5 ✔
2058
            this.variables[refId] = {
1 ✔
2059
                variablesReference: refId,
2060
                name: 'Locals',
2061
                value: '',
2062
                type: '$$Locals',
2063
                frameId: frameId,
2064
                isScope: true,
2065
                childVariables: []
2066
            };
2067
        }
2068
        return this.variables[refId];
5 ✔
2069
    }
2070

2071
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
2072
        //if we have a compile error, we should shut down
UNCOV
2073
        if (this.compileError) {
×
UNCOV
2074
            this.sendResponse(response);
×
UNCOV
2075
            await this.shutdown();
×
UNCOV
2076
            return;
×
2077
        }
2078

2079
        this.logger.log('continueRequest');
×
2080
        await this.setTransientsToInvalid(); // call before clearState
×
2081
        this.clearState();
×
2082

2083
        // The debug session ends after the next line. Do not put new work after this line.
2084
        await this.rokuAdapter.continue();
×
2085
        this.sendResponse(response);
×
2086
    }
2087

2088
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
2089
        this.logger.log('pauseRequest');
×
2090

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

2098
        await this.rokuAdapter.pause();
×
2099
        this.sendResponse(response);
×
2100
    }
2101

2102
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
2103
        this.logger.log('reverseContinueRequest');
×
2104
        this.sendResponse(response);
×
2105
    }
2106

2107
    /**
2108
     * Clicked the "Step Over" button
2109
     * @param response
2110
     * @param args
2111
     */
2112
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
UNCOV
2113
        this.logger.log('[nextRequest] begin');
×
2114

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

2122
        await this.setTransientsToInvalid(); // call before clearState
×
2123
        this.clearState();
×
2124

2125
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
2126
        try {
×
2127
            await this.rokuAdapter.stepOver(args.threadId);
×
2128
            this.logger.info('[nextRequest] end');
×
2129
        } catch (error) {
UNCOV
2130
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
2131
        }
2132
        this.sendResponse(response);
×
2133
    }
2134

2135
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
UNCOV
2136
        this.logger.log('[stepInRequest]');
×
2137

2138
        //if we have a compile error, we should shut down
UNCOV
2139
        if (this.compileError) {
×
UNCOV
2140
            this.sendResponse(response);
×
2141
            await this.shutdown();
×
UNCOV
2142
            return;
×
2143
        }
2144

2145
        await this.setTransientsToInvalid(); // call before clearState
×
2146
        this.clearState();
×
2147
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
2148
        await this.rokuAdapter.stepInto(args.threadId);
×
UNCOV
2149
        this.sendResponse(response);
×
2150
        this.logger.info('[stepInRequest] end');
×
2151
    }
2152

2153
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
2154
        this.logger.log('[stepOutRequest] begin');
×
2155

2156
        //if we have a compile error, we should shut down
UNCOV
2157
        if (this.compileError) {
×
UNCOV
2158
            this.sendResponse(response);
×
2159
            await this.shutdown();
×
UNCOV
2160
            return;
×
2161
        }
2162

2163
        await this.setTransientsToInvalid(); // call before clearState
×
2164
        this.clearState();
×
2165

2166
        // The debug session ends after the next line. Do not put new work after this line.
UNCOV
2167
        await this.rokuAdapter.stepOut(args.threadId);
×
2168
        this.sendResponse(response);
×
2169
        this.logger.info('[stepOutRequest] end');
×
2170
    }
2171

2172
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
2173
        this.logger.log('[stepBackRequest] begin');
×
2174
        this.sendResponse(response);
×
UNCOV
2175
        this.logger.info('[stepBackRequest] end');
×
2176
    }
2177

2178
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
2179
        const logger = this.logger.createLogger('[variablesRequest]');
4 ✔
2180
        let sendInvalidatedEvent = false;
4 ✔
2181
        let frameId: number = null;
4 ✔
2182
        try {
4 ✔
2183
            logger.log('begin', { args });
4 ✔
2184

2185
            //ensure the rokuAdapter is loaded
2186
            await this.getRokuAdapter();
4 ✔
2187

2188
            let updatedVariables: AugmentedVariable[] = [];
4 ✔
2189
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
2190
            await this.evaluateRequestPromise;
4 ✔
2191
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4 !
UNCOV
2192
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
UNCOV
2193
                response.success = false;
×
UNCOV
2194
                response.message = 'Debug session is not paused';
×
UNCOV
2195
                return this.sendResponse(response);
×
2196
            }
2197

2198
            //find the variable with this reference
2199
            let v = this.variables[args.variablesReference];
4 ✔
2200
            if (!v) {
4 !
UNCOV
2201
                response.success = false;
×
UNCOV
2202
                response.message = `Variable reference has expired`;
×
UNCOV
2203
                return this.sendResponse(response);
×
2204
            }
2205
            logger.log('variable', v);
4 ✔
2206

2207
            // Populate scope level values if needed
2208
            if (v.isScope) {
4 ✔
2209
                await this.populateScopeVariables(v, args);
2 ✔
2210
            }
2211

2212
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
2213
            if (v.childVariables.length === 0 || v.isResolved) {
4 !
2214
                let tempVar: AugmentedVariable;
UNCOV
2215
                if (!v.isResolved) {
×
2216
                    // Evaluate the variable
UNCOV
2217
                    try {
×
UNCOV
2218
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
UNCOV
2219
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
2220
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
UNCOV
2221
                        tempVar.frameId = v.frameId;
×
2222
                        // Determine if the variable has changed
2223
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
2224
                    } catch (error) {
2225
                        logger.error('Error getting variables', error);
×
2226
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
UNCOV
2227
                        tempVar.type = '';
×
2228
                        tempVar.childVariables = [];
×
UNCOV
2229
                        sendInvalidatedEvent = true;
×
2230
                        response.success = false;
×
2231
                        response.message = error.message;
×
2232
                    }
2233

2234
                    // Merge the resulting updates together
2235
                    v.childVariables = tempVar.childVariables;
×
2236
                    v.value = tempVar.value;
×
UNCOV
2237
                    v.type = tempVar.type;
×
UNCOV
2238
                    v.indexedVariables = tempVar.indexedVariables;
×
UNCOV
2239
                    v.namedVariables = tempVar.namedVariables;
×
2240
                }
2241
                frameId = v.frameId;
×
2242

2243
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2244
                    // If this was a lazy variable we need to respond with the updated variable and not the children
UNCOV
2245
                    if (v.isResolved && v.childVariables.length > 0) {
×
2246
                        updatedVariables = v.childVariables;
×
2247
                    } else {
2248
                        updatedVariables = [v];
×
2249
                    }
2250
                    v.isResolved = true;
×
2251
                } else {
UNCOV
2252
                    updatedVariables = v.childVariables;
×
2253
                }
2254

2255
                // If the variable has no children, set the reference to 0
2256
                // so it does not look expandable in the Ui
2257
                if (v.childVariables.length === 0) {
×
UNCOV
2258
                    v.variablesReference = 0;
×
2259
                }
2260

2261
                // If the variable was resolve in the past we may not have fetched a new temp var
2262
                tempVar ??= v;
×
2263
                if (v?.presentationHint) {
×
UNCOV
2264
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2265
                } else {
UNCOV
2266
                    v.presentationHint = tempVar.presentationHint;
×
2267
                }
2268

2269
            } else {
2270
                updatedVariables = v.childVariables;
4 ✔
2271
            }
2272

2273
            // Only send the updated variables if we are not going to trigger an invalidated event.
2274
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2275
            response.body = {
4 ✔
2276
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2277
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2278
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2279
            };
2280
        } catch (error) {
UNCOV
2281
            logger.error('Error during variablesRequest', error, { args });
×
UNCOV
2282
            response.success = false;
×
UNCOV
2283
            response.message = error?.message ?? 'Error during variablesRequest';
×
2284
        } finally {
2285
            logger.info('end', { response });
4 ✔
2286
        }
2287
        this.sendResponse(response);
4 ✔
2288
        if (sendInvalidatedEvent) {
4 !
UNCOV
2289
            this.debounceSendInvalidatedEvent(null, frameId);
×
2290
        }
2291
    }
2292

2293
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
229 ✔
2294
        this.sendInvalidatedEvent(threadId, frameId);
×
2295
    }, 50);
2296

2297

2298
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2299
        if (!updatedVariables || !v) {
4 !
UNCOV
2300
            return [];
×
2301
        }
2302

2303
        let start = args.start ?? 0;
4 !
2304

2305
        //if the variable is an array, send only the requested range
2306
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4 !
2307
            //only send the variable range requested by the debugger
UNCOV
2308
            if (!args.count) {
×
UNCOV
2309
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2310
            } else {
UNCOV
2311
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2312
            }
2313
        }
2314

2315
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4 !
2316
            // We currently do not support named variable paging so we always send all named variables
2317
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4 ✔
2318
        }
2319

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

2323
        if (this.launchConfiguration.showHiddenVariables !== true) {
4 ✔
2324
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2 ✔
2325
                //A transient variable that we show when there is a value
2326
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4 !
UNCOV
2327
                    return true;
×
2328
                } else if (util.isTransientVariable(child.name)) {
4 !
UNCOV
2329
                    return false;
×
2330
                } else {
2331
                    return true;
4 ✔
2332
                }
2333
            });
2334
        }
2335

2336
        return filteredUpdatedVariables;
4 ✔
2337
    }
2338

2339
    /**
2340
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2341
     * @param v scope variable to populate
2342
     * @param args
2343
     */
2344
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2345
        if (v.childVariables.length > 0) {
4 ✔
2346
            // Already populated
2347
            return;
3 ✔
2348
        }
2349

2350
        let tempVar: AugmentedVariable;
2351
        try {
1 ✔
2352
            if (v.type === '$$Locals') {
1 !
2353
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1 !
2354
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1 ✔
2355
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1 ✔
UNCOV
2356
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2357
                    // NOTE: Legacy telnet support
UNCOV
2358
                    let variables: AugmentedVariable[] = [];
×
UNCOV
2359
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2360

2361
                    // Fetch each variable individually
UNCOV
2362
                    for (const varName of varNames) {
×
2363
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
2364
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
UNCOV
2365
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
UNCOV
2366
                        variables.push(tempLocalsVar);
×
2367
                    }
2368
                    tempVar = {
×
2369
                        ...v,
2370
                        childVariables: variables,
2371
                        namedVariables: variables.length,
2372
                        indexedVariables: 0
2373
                    };
2374
                }
2375

2376
                // Merge the resulting updates together onto the original variable
2377
                v.childVariables = tempVar.childVariables;
1 ✔
2378
                v.namedVariables = tempVar.namedVariables;
1 ✔
2379
                v.indexedVariables = tempVar.indexedVariables;
1 ✔
UNCOV
2380
            } else if (v.type === '$$Registry') {
×
2381
                // This is a special scope variable used to load registry data via an ECP call
2382
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
UNCOV
2383
                await populateVariableFromRegistryEcp({ remotePort: this.launchConfiguration.remotePort, device: this.launchConfiguration.device, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2384
            }
2385
        } catch (error) {
UNCOV
2386
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
UNCOV
2387
            tempVar = {
×
2388
                name: '',
2389
                value: `❌ Error: ${error.message}`,
2390
                variablesReference: 0,
2391
                childVariables: []
2392
            };
UNCOV
2393
            v.childVariables = [tempVar];
×
UNCOV
2394
            v.namedVariables = 1;
×
UNCOV
2395
            v.indexedVariables = 0;
×
2396
        }
2397

2398
        // Mark the scope as resolved so we don't re-fetch the variables
2399
        v.isResolved = true;
1 ✔
2400

2401
        // If the scope has no children, add a single child to indicate there are no values
2402
        if (v.childVariables.length === 0) {
1 !
UNCOV
2403
            tempVar = {
×
2404
                name: '',
2405
                value: `No values for scope '${v.name}'`,
2406
                variablesReference: 0,
2407
                childVariables: []
2408
            };
UNCOV
2409
            v.childVariables = [tempVar];
×
UNCOV
2410
            v.namedVariables = 1;
×
UNCOV
2411
            v.indexedVariables = 0;
×
2412
        }
2413
    }
2414

2415
    private evaluateRequestPromise = Promise.resolve();
229 ✔
2416
    private evaluateVarIndexByFrameId = new Map<number, number>();
229 ✔
2417

2418
    private getNextVarIndex(frameId: number): number {
2419
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6 ✔
2420
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5 ✔
2421
        }
2422
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6 ✔
2423
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6 ✔
2424
        return value;
6 ✔
2425
    }
2426

2427
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2428
        //ensure the rokuAdapter is loaded
2429
        await this.getRokuAdapter();
15 ✔
2430

2431
        let deferred = defer<void>();
15 ✔
2432
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15 !
UNCOV
2433
            this.clearState();
×
UNCOV
2434
            this.rokuAdapter.clearCache();
×
UNCOV
2435
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
UNCOV
2436
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
UNCOV
2437
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
2438
            this.sendResponse(response);
×
2439
            return deferred.promise;
×
2440
        }
2441

2442
        try {
15 ✔
2443
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15 ✔
2444
                return deferred.promise;
15 ✔
2445
            });
2446

2447
            //fix vscode hover bug that excludes closing quotemark sometimes.
2448
            if (args.context === 'hover') {
15 ✔
2449
                args.expression = util.ensureClosingQuote(args.expression);
4 ✔
2450
            }
2451

2452
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15 ✔
2453
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1 ✔
2454
                if (args.context === 'repl') {
1 !
2455
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1 ✔
2456
                    response.body = {
1 ✔
2457
                        result: 'invalid',
2458
                        variablesReference: 0
2459
                    };
2460
                } else {
UNCOV
2461
                    throw new Error(message);
×
2462
                }
2463

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

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

2471
                //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`
2472
                if (variablePath) {
14 ✔
2473
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11 ✔
2474
                    let v: AugmentedVariable;
2475
                    //if we already looked this item up, return it
2476
                    if (this.variables[refId]) {
11 ✔
2477
                        v = this.variables[refId];
1 ✔
2478
                    } else {
2479
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10 ✔
2480
                        if (!result) {
10 !
UNCOV
2481
                            throw new Error('Error: unable to evaluate expression');
×
2482
                        }
2483

2484
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10 ✔
2485
                        //TODO - testing something, remove later
2486
                        // eslint-disable-next-line camelcase
2487
                        v.request_seq = response.request_seq;
10 ✔
2488
                        v.frameId = evalArgs.frameId;
10 ✔
2489
                    }
2490
                    response.body = {
11 ✔
2491
                        result: v.value,
2492
                        type: v.type,
2493
                        variablesReference: v.variablesReference,
2494
                        namedVariables: v.namedVariables || 0,
21 ✔
2495
                        indexedVariables: v.indexedVariables || 0
21 ✔
2496
                    };
2497

2498
                    //run an `evaluate` call
2499
                } else {
2500
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3 ✔
2501

2502
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3 ✔
2503
                    if (args.context === 'repl') {
3 !
2504
                        // Clear variable cache since this action could have side-effects
2505
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2506
                        this.clearState();
3 ✔
2507
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3 ✔
2508
                    }
2509

2510
                    // If the adapter captured output (probably only telnet), log the results
2511
                    if (typeof commandResults.message === 'string') {
3 ✔
2512
                        this.logger.debug('evaluateRequest', { commandResults });
1 ✔
2513
                        if (args.context === 'repl') {
1 !
2514
                            // If the command was a repl command, send the output to the debug console for the developer as well
2515
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2516
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1 !
2517
                        }
2518
                    }
2519

2520
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3 ✔
2521
                        response.body = {
2 ✔
2522
                            result: 'invalid',
2523
                            variablesReference: 0
2524
                        };
2525
                    } else {
2526
                        response.body = {
1 ✔
2527
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1 !
2528
                            variablesReference: 0
2529
                        };
2530
                    }
2531
                }
2532
            }
2533
        } catch (error) {
UNCOV
2534
            this.logger.error('Error during variables request', error);
×
UNCOV
2535
            response.success = false;
×
UNCOV
2536
            response.message = error?.message ?? error;
×
2537
        }
2538
        try {
15 ✔
2539
            this.sendResponse(response);
15 ✔
2540
        } catch { }
2541
        deferred.resolve();
15 ✔
2542
    }
2543

2544
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2545
        let returnVal = { evalArgs: args, variablePath };
19 ✔
2546
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19 ✔
2547
            let varIndex = this.getNextVarIndex(args.frameId);
6 ✔
2548
            let arrayVarName = this.tempVarPrefix + 'eval';
6 ✔
2549
            let command = '';
6 ✔
2550
            if (varIndex === 0) {
6 ✔
2551
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5 ✔
2552
            }
2553
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6 ✔
2554
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6 ✔
2555
            command += statement;
6 ✔
2556
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6 ✔
2557
            if (commandResults.type === 'error') {
6 !
UNCOV
2558
                throw new Error(commandResults.message);
×
2559
            }
2560
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6 ✔
2561
        }
2562
        return returnVal;
19 ✔
2563
    }
2564

2565
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
UNCOV
2566
        let results = {
×
2567
            evaluations: [],
2568
            bulkVarName: ''
2569
        };
UNCOV
2570
        let storedVariables = [];
×
2571
        let command = '';
×
UNCOV
2572
        for (let i = 0; i < argsArray.length; i++) {
×
UNCOV
2573
            let args = argsArray[i];
×
UNCOV
2574
            let variablePath = variablePathArray[i];
×
2575
            let returnVal = { evalArgs: args, variablePath };
×
2576
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
2577
                let varIndex = this.getNextVarIndex(frameId);
×
2578
                let arrayVarName = this.tempVarPrefix + 'eval';
×
2579
                if (varIndex === 0) {
×
2580
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2581
                }
2582
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
2583
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
2584
                command += statement;
×
2585

UNCOV
2586
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
2587
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2588
            }
2589

UNCOV
2590
            results.evaluations[i] = returnVal;
×
2591
        }
2592

UNCOV
2593
        if (command) {
×
2594

2595
            // create a bulk container for the command results
UNCOV
2596
            let varIndex = this.getNextVarIndex(frameId);
×
UNCOV
2597
            let arrayVarName = this.tempVarPrefix + 'eval';
×
2598
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
UNCOV
2599
            for (let storedVariable of storedVariables) {
×
UNCOV
2600
                bulkContainerStatement += `${storedVariable},\n`;
×
2601
            }
2602
            bulkContainerStatement += `]`;
×
2603

2604
            command += bulkContainerStatement;
×
2605

UNCOV
2606
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2607

UNCOV
2608
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2609
            if (commandResults.type === 'error') {
×
UNCOV
2610
                throw new Error(commandResults.message);
×
2611
            }
2612
        }
2613

2614
        return results;
×
2615
    }
2616

2617
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2618
        this.logger.log('completionsRequest', args, request);
20 ✔
2619
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2620
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2621

2622
        try {
20 ✔
2623
            let supplyLocalScopeCompletions = false;
20 ✔
2624

2625
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20 ✔
2626

2627
            if (!closestCompletionDetails) {
20 !
2628
                // If the cursor is not at the end of the line, then we should not supply completions at this time
UNCOV
2629
                response.body = {
×
2630
                    targets: []
2631
                };
UNCOV
2632
                return this.sendResponse(response);
×
2633
            }
2634
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20 ✔
2635

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

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

2653
            // Get the completions if the variable path was valid
2654
            if (parentVariablePath) {
20 !
2655

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

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

2664
                // provide completions for the parent variable if one was found
2665
                if (parentVariable) {
20 ✔
2666
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2667
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2668
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2669
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19 ✔
2670
                        parentVariable.type === VariableType.List ||
2671
                        parentVariable.type === 'roXMLList' ||
2672
                        parentVariable.type === 'roByteArray';
2673

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

2679
                    for (let v of possibleFieldsAndMethods) {
19 ✔
2680
                        // Default completion type should be variable
2681
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21 ✔
2682
                        if (!supplyLocalScopeCompletions) {
21 ✔
2683
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2684
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17 !
2685
                                completionType = 'field';
17 ✔
2686
                            }
2687

2688
                            switch (v.type) {
17 !
2689
                                case VariableType.Function:
2690
                                case VariableType.Subroutine:
UNCOV
2691
                                    completionType = 'method';
×
UNCOV
2692
                                    break;
×
2693
                                default:
2694
                                    break;
17 ✔
2695
                            }
2696
                        }
2697

2698
                        const completionItem: DebugProtocol.CompletionItem = {
21 ✔
2699
                            label: v.name,
2700
                            type: completionType,
2701
                            //rank a variable's own members/locals above everything else
2702
                            sortText: `${CompletionSortTier.Member}${v.name}`
2703
                        };
2704
                        if (stringKeyClosing !== undefined) {
21 ✔
2705
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2706
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2707
                            // (ex: a key of `a"b` is inserted as `a""b`).
2708
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4 ✔
2709
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17 ✔
2710
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2711
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2712
                            // the key is escaped as `""` so the inserted string literal stays valid.
2713
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3 ✔
2714
                            completionItem.start = replaceRange.start - 1;
3 ✔
2715
                            completionItem.length = replaceRange.length + 1;
3 ✔
2716
                        }
2717
                        completions.set(`${completionType}-${v.name}`, completionItem);
21 ✔
2718
                    }
2719

2720
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2721
                    if (stringKeyClosing === undefined) {
19 ✔
2722
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15 ✔
2723
                        //assemble a list of all methods on the parent component
2724
                        const methods = [
15 ✔
2725
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2726
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90 !
2727
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2728
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29 !
2729
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2730
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90 !
2731
                        ].flat();
2732

2733
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2734
                        for (const method of methods) {
15 ✔
2735
                            completions.set(`method-${method.name}`, {
185 ✔
2736
                                label: method.name,
2737
                                type: 'method',
2738
                                detail: method.description ?? '',
555 !
2739
                                sortText: `${CompletionSortTier.Method}${method.name}`
2740
                            });
2741
                        }
2742
                    }
2743

2744
                    // Add the global functions to the completions results
2745
                    if (supplyLocalScopeCompletions) {
19 ✔
2746
                        for (let globalCallable of globalCallables) {
4 ✔
2747
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308 ✔
2748
                                label: globalCallable.name,
2749
                                type: 'function',
2750
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848 !
2751
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2752
                            });
2753
                        }
2754

2755
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4 ✔
2756

2757
                        try {
4 ✔
2758
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4 ✔
2759
                            for (let scopeFunction of scopeFunctions) {
4 ✔
2760
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1 !
2761
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1 ✔
2762
                                        label: scopeFunction.name,
2763
                                        type: scopeFunction.completionItemKind,
2764
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2765
                                    });
2766
                                }
2767
                            }
2768
                        } catch (e) {
UNCOV
2769
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2770
                        }
2771
                    }
2772
                }
2773
            }
2774

2775
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2776
            // rewrites above use an extended range that also consumes the preceding `.`).
2777
            for (const target of completions.values()) {
20 ✔
2778
                if (target.start === undefined) {
499 ✔
2779
                    target.start = replaceRange.start;
496 ✔
2780
                    target.length = replaceRange.length;
496 ✔
2781
                }
2782
            }
2783

2784
            response.body = {
20 ✔
2785
                targets: [...completions.values()]
2786
            };
2787
        } catch (error) {
2788
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2789
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
UNCOV
2790
            this.logger.error('Error during completionsRequest', error, { args });
×
2791
        }
2792
        this.sendResponse(response);
20 ✔
2793
    }
2794

2795
    /**
2796
     * Gets the closest completion details the incoming completion request.
2797
     */
2798
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2799
        const incomingText = args.text;
69 ✔
2800
        const lines = incomingText.split('\n');
69 ✔
2801
        let lineNumber = this.toDebuggerLine(args.line, 0);
69 ✔
2802
        let column = this.toDebuggerColumn(args.column);
69 ✔
2803

2804
        const targetLine = lines[lineNumber] ?? '';
69 !
2805

2806
        const cursorIndex = column - 1;
69 ✔
2807
        const variableChars = /[a-z0-9_\.]/i;
69 ✔
2808

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

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

2824
        const openBracket = this.findUnclosedOpener(targetLine, column);
67 ✔
2825
        if (openBracket?.char === '[') {
67 ✔
2826
            // find the opening quote (skipping any whitespace after the `[`)
2827
            let quoteIndex = openBracket.index + 1;
12 ✔
2828
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12 ✔
UNCOV
2829
                quoteIndex++;
×
2830
            }
2831
            const quote = targetLine[quoteIndex];
12 ✔
2832
            if (quote === '"' || quote === `'`) {
12 ✔
2833
                // The user is typing a string key, so complete the keys of the expression before the `[`
2834
                endColumn = openBracket.index;
8 ✔
2835
                isMemberAccess = true;
8 ✔
2836
                // close the string and bracket only when there is nothing meaningful after the cursor
2837
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8 ✔
2838
            }
2839
        }
2840

2841
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2842
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2843
        let startIndex = endColumn - 1;
67 ✔
2844
        let bracketDepth = 0;
67 ✔
2845
        while (startIndex >= 0) {
67 ✔
2846
            const char = targetLine[startIndex];
438 ✔
2847
            if (char === ']') {
438 ✔
2848
                bracketDepth++;
10 ✔
2849
            } else if (char === '[') {
428 ✔
2850
                if (bracketDepth === 0) {
12 ✔
2851
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2852
                    break;
2 ✔
2853
                }
2854
                bracketDepth--;
10 ✔
2855
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416 ✔
2856
                break;
23 ✔
2857
            }
2858
            startIndex--;
413 ✔
2859
        }
2860

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

2863
        // Attempted dot access on something unexpected.
2864
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2865
        // which leaves `.name` as the variable path string.
2866
        if (variablePathString.startsWith('.')) {
67 ✔
2867
            return undefined;
2 ✔
2868
        }
2869

2870
        if (variablePathString.endsWith('.')) {
65 ✔
2871
            isMemberAccess = true;
25 ✔
2872
        }
2873

2874
        // Get the variable path from the text
2875
        let variablePath: string[];
2876
        if (!variablePathString.trim()) {
65 ✔
2877
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2878
            variablePath = [''];
10 ✔
2879
        } else if (variablePathString.endsWith('.')) {
55 ✔
2880
            // supplied text ends with a period, so strip it off to create a valid variable path
2881
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25 ✔
2882
        } else {
2883
            variablePath = util.getVariablePath(variablePathString);
30 ✔
2884
        }
2885

2886
        // the target string is not a valid variable path
2887
        if (!variablePath) {
65 ✔
2888
            return undefined;
2 ✔
2889
        }
2890

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

2895
        // An empty parent path means we are looking up the local scope variables and global functions
2896
        if (parentVariablePath.length === 0) {
63 ✔
2897
            parentVariablePath = [''];
17 ✔
2898
        }
2899

2900
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63 ✔
2901
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2902
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63 ✔
2903
            result.stringKeyClosing = stringKeyClosing;
8 ✔
2904
        }
2905
        return result;
63 ✔
2906
    }
2907

2908
    /**
2909
     * Compute the span of input text that a completion replaces: the run of identifier characters
2910
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2911
     * typing past the first character.
2912
     *
2913
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2914
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2915
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2916
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2917
     */
2918
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2919
        const lines = args.text.split('\n');
20 ✔
2920
        const lineNumber = this.toDebuggerLine(args.line, 0);
20 ✔
2921
        const cursorOffset = this.toDebuggerColumn(args.column);
20 ✔
2922
        const targetLine = lines[lineNumber] ?? '';
20 !
2923

2924
        const identifierChars = /[a-z0-9_]/i;
20 ✔
2925
        let wordStart = cursorOffset;
20 ✔
2926
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20 ✔
2927
            wordStart--;
7 ✔
2928
        }
2929
        return {
20 ✔
2930
            start: wordStart,
2931
            length: cursorOffset - wordStart
2932
        };
2933
    }
2934

2935
    /**
2936
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2937
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2938
     */
2939
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2940
        let depth = 0;
67 ✔
2941
        for (let i = column - 1; i >= 0; i--) {
67 ✔
2942
            const char = line[i];
604 ✔
2943
            if (char === ')' || char === ']' || char === '}') {
604 ✔
2944
                depth++;
12 ✔
2945
            } else if (char === '(' || char === '[' || char === '{') {
592 ✔
2946
                if (depth === 0) {
34 ✔
2947
                    return { index: i, char: char };
22 ✔
2948
                }
2949
                depth--;
12 ✔
2950
            }
2951
        }
2952
        return undefined;
45 ✔
2953
    }
2954

2955
    /**
2956
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2957
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2958
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2959
     */
2960
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2961
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2962
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2963
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20 ✔
2964
        if (isLocalScope) {
20 ✔
2965
            const localsScope = this.getOrCreateLocalsScope(frameId);
4 ✔
2966
            if (!localsScope.isResolved) {
4 !
2967
                try {
4 ✔
2968
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4 ✔
2969
                } catch (error) {
UNCOV
2970
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2971
                }
2972
            }
2973
        }
2974

2975
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20 ✔
2976
        if (inMemory && inMemory.childVariables.length > 0) {
20 ✔
2977
            return inMemory;
14 ✔
2978
        }
2979

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

2984
        const cacheKey = `${frameId}:${expression}`;
6 ✔
2985
        if (this.completionParentVariableCache.has(cacheKey)) {
6 ✔
2986
            return this.completionParentVariableCache.get(cacheKey);
1 ✔
2987
        }
2988

2989
        let parentVariable: AugmentedVariable;
2990
        try {
5 ✔
2991
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5 ✔
2992
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5 ✔
2993
            parentVariable = await this.getVariableFromResult(result, frameId);
4 ✔
2994
        } catch (error) {
2995
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2996
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1 ✔
2997
            parentVariable = undefined;
1 ✔
2998
        }
2999

3000
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5 ✔
3001
        return parentVariable;
5 ✔
3002
    }
3003

3004
    /**
3005
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
3006
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
3007
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
3008
     * use dot access. This keeps array indices and string keys correct through the device lookup.
3009
     */
3010
    private buildVariableExpression(segments: string[]): string {
3011
        return segments.reduce((expression, segment, index) => {
14 ✔
3012
            if (index === 0) {
27 ✔
3013
                return segment;
14 ✔
3014
            }
3015
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
3016
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
3017
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13 ✔
3018
                return `${expression}[${segment}]`;
2 ✔
3019
            }
3020
            if (/^[0-9]+$/.test(segment)) {
11 ✔
3021
                return `${expression}[${segment}]`;
3 ✔
3022
            }
3023
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8 ✔
3024
                return `${expression}.${segment}`;
5 ✔
3025
            }
3026
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3 ✔
3027
        }, '');
3028
    }
3029

3030
    /**
3031
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
3032
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
3033
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
3034
     * the user typed (ex: `topRef` matching the cached `topref`).
3035
     */
3036
    private normalizeVariableName(name: string): string {
3037
        let value = name ?? '';
46 !
3038
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46 ✔
3039
            value = value.slice(1, -1).replace(/""/g, '"');
2 ✔
3040
        }
3041
        return value.toLowerCase();
46 ✔
3042
    }
3043

3044
    /**
3045
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
3046
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
3047
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
3048
     */
3049
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
3050
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20 ✔
3051
        if (path.length === 1 && path[0] === '') {
20 ✔
3052
            return localsContainer;
4 ✔
3053
        }
3054
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16 ✔
3055
    }
3056

3057
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
3058
        let current: AugmentedVariable = null;
22 ✔
3059
        for (const name of path) {
22 ✔
3060
            const normalizedName = this.normalizeVariableName(name);
26 ✔
3061
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
3062
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26 !
3063
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20 ✔
3064
            });
3065

3066
            // If no match is found, return null
3067
            if (!current) {
26 ✔
3068
                return null;
7 ✔
3069
            }
3070

3071
            // Move to the children for the next iteration
3072
            variables = current.childVariables;
19 ✔
3073
        }
3074
        return current;
15 ✔
3075
    }
3076

3077
    private debuggerVarTypeToRoType(type: string): string {
3078
        switch (type) {
15 !
3079
            case VariableType.Function:
3080
            case VariableType.Subroutine:
UNCOV
3081
                return 'roFunction';
×
3082
            case VariableType.AssociativeArray:
3083
                return 'roAssociativeArray';
11 ✔
3084
            case VariableType.List:
UNCOV
3085
                return 'roList';
×
3086
            case VariableType.Array:
3087
                return 'roArray';
1 ✔
3088
            case VariableType.Boolean:
UNCOV
3089
                return 'roBoolean';
×
3090
            case VariableType.Double:
UNCOV
3091
                return 'roDouble';
×
3092
            case VariableType.Float:
UNCOV
3093
                return 'roFloat';
×
3094
            case VariableType.Integer:
UNCOV
3095
                return 'roInteger';
×
3096
            case VariableType.LongInteger:
UNCOV
3097
                return 'roLongInteger';
×
3098
            case VariableType.String:
UNCOV
3099
                return 'roString';
×
3100
            default:
3101
                return type;
3 ✔
3102
        }
3103
    }
3104

3105
    /**
3106
     * Called when the host stops debugging
3107
     * @param response
3108
     * @param args
3109
     */
3110
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
3111
        //return to the home screen — best effort. The device may already be powered off or unreachable
3112
        //at disconnect time; without a guard the home key press rejects (EHOSTDOWN / ECONNREFUSED / etc)
3113
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
3114
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
3115
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
3116
        //    https://github.com/rokucommunity/roku-debug/issues/332
3117
        if (!this.enableDebugProtocol) {
2 !
3118
            try {
2 ✔
3119
                await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
2 ✔
3120
            } catch (e) {
3121
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2 ✔
3122
            }
3123
        }
3124
        this.sendResponse(response);
2 ✔
3125
        await this.shutdown();
2 ✔
3126
    }
3127

3128
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
3129
        if (this.enableDebugProtocol) {
1 !
UNCOV
3130
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
3131
        } else {
3132
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1 ✔
3133
        }
3134
    }
3135

3136
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
UNCOV
3137
        this.logger.log('[restartRequest] begin');
×
UNCOV
3138
        if (this.rokuAdapter) {
×
UNCOV
3139
            if (!this.enableDebugProtocol) {
×
UNCOV
3140
                this.rokuAdapter.removeAllListeners();
×
3141
            }
3142
            await this.rokuAdapter.destroy();
×
3143
            await this.ensureAppIsInactive();
×
3144
            this.rokuAdapterDeferred = defer();
×
3145
            this.stagingDefered.tryResolve();
×
UNCOV
3146
            this.stagingDefered = defer();
×
3147
        }
3148
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
3149
    }
3150

3151
    private exitAppTimeout = 5000;
229 ✔
3152
    private async ensureAppIsInactive() {
3153
        const startTime = Date.now();
×
3154

UNCOV
3155
        while (true) {
×
UNCOV
3156
            if (Date.now() - startTime > this.exitAppTimeout) {
×
UNCOV
3157
                return;
×
3158
            }
3159

3160
            try {
×
3161
                let appStateResult = await rokuECP.getAppState({
×
3162
                    remotePort: this.launchConfiguration.remotePort,
3163
                    device: this.launchConfiguration.device,
3164
                    appId: 'dev',
3165
                    requestOptions: { timeout: 300 }
3166
                });
3167

UNCOV
3168
                const state = appStateResult.state;
×
3169

UNCOV
3170
                if (state === AppState.active || state === AppState.background) {
×
3171
                    // Suspends or terminates an app that is running:
3172
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
3173
                    // 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.
3174
                    // This means that we might need to send this command twice to terminate the app.
3175
                    await rokuECP.exitApp({
×
3176
                        remotePort: this.launchConfiguration.remotePort,
3177
                        device: this.launchConfiguration.device,
3178
                        appId: 'dev',
3179
                        requestOptions: { timeout: 300 }
3180
                    });
UNCOV
3181
                } else if (state === AppState.inactive) {
×
UNCOV
3182
                    return;
×
3183
                }
3184
            } catch (e) {
UNCOV
3185
                this.logger.error('Error attempting to exit application', e);
×
3186
            }
3187

UNCOV
3188
            await util.sleep(200);
×
3189
        }
3190
    }
3191

3192
    /**
3193
     * Used to track whether the entry breakpoint has already been handled
3194
     */
3195
    private entryBreakpointWasHandled = false;
229 ✔
3196

3197
    /**
3198
     * Registers the main events for the RokuAdapter
3199
     */
3200
    private async connectRokuAdapter() {
UNCOV
3201
        this.rokuAdapter.on('start', () => {
×
UNCOV
3202
            this.sendLaunchProgress('end', 'Complete');
×
UNCOV
3203
            if (!this.firstRunDeferred.isCompleted) {
×
UNCOV
3204
                this.firstRunDeferred.resolve();
×
3205
            }
3206
        });
3207

3208
        this.rokuAdapter.on('launch-status', (message) => {
×
3209
            this.sendLaunchProgress('update', message);
×
3210
        });
3211

3212
        //when the debugger suspends (pauses for debugger input)
3213
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3214
        this.rokuAdapter.on('suspend', async () => {
×
UNCOV
3215
            await this.onSuspend();
×
3216
        });
3217

3218
        //anytime the adapter encounters an exception on the roku,
3219
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3220
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
UNCOV
3221
            await this.getRokuAdapter();
×
UNCOV
3222
            const threads = await this.setupSuspendedState();
×
UNCOV
3223
            let threadId = threads[0]?.threadId;
×
UNCOV
3224
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
3225
        });
3226

3227
        // If the roku says it can't continue, we are no longer able to debug, so kill the debug session
3228
        this.rokuAdapter.on('cannot-continue', () => {
×
3229
            this.shutdown().catch(e => this.logger.error(e));
×
3230
        });
3231

3232
        //make the connection
3233
        await this.rokuAdapter.connect();
×
3234
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
UNCOV
3235
        return this.rokuAdapter;
×
3236
    }
3237

3238
    private async onSuspend() {
3239
        const threads = await this.setupSuspendedState();
1 ✔
3240
        const activeThread = threads.find(x => x.isSelected);
1 ✔
3241

3242
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3243
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1 !
3244
            this.entryBreakpointWasHandled = true;
1 ✔
3245
            //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
3246
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1 !
UNCOV
3247
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
UNCOV
3248
                return this.rokuAdapter.continue();
×
3249
            }
3250
        }
3251

3252
        const event: StoppedEvent = new StoppedEvent(
1 ✔
3253
            StoppedEventReason.breakpoint,
3254
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
3255
            activeThread?.threadId ?? 0,
6 !
3256
            '' //exception text
3257
        );
3258
        // Socket debugger will always stop all threads and supports multi thread inspection.
3259
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1 ✔
3260
        this.sendEvent(event);
1 ✔
3261
    }
3262

3263
    private async setupSuspendedState() {
3264
        //clear the index for storing evalutated expressions
3265
        this.evaluateVarIndexByFrameId.clear();
8 ✔
3266

3267
        const threads = await this.rokuAdapter.getThreads();
8 ✔
3268

3269
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3270
        //look up the correct line numbers for each thread from the StackTrace
3271
        await Promise.all(
8 ✔
3272
            threads.map(async (thread) => {
3273
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9 ✔
3274
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9 ✔
3275
                const stackTraceFilePath = stackTrace[0]?.filePath;
9 ✔
3276
                // Only apply the line correction when we actually have valid data — never clobber
3277
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3278
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9 ✔
3279
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2 ✔
3280
                    thread.lineNumber = stackTraceLineNumber;
2 ✔
3281
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2 !
3282
                }
3283
            })
3284
        );
3285

3286
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8 ✔
3287
            for (const thread of threads) {
4 ✔
3288
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4 ✔
3289
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3290
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3291
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4 ✔
3292
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1 ✔
UNCOV
3293
                        this.logger.error('Error showing popup message', { error });
×
3294
                    });
3295
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1 ✔
3296
                    break outer;
1 ✔
3297
                }
3298
            }
3299
        }
3300

3301
        //sync breakpoints
3302
        await this.rokuAdapter?.syncBreakpoints();
8 !
3303

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

3306
        this.clearState();
8 ✔
3307
        return threads;
8 ✔
3308
    }
3309

3310
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15 ✔
3311
        let v: AugmentedVariable;
3312

3313
        if (result) {
25 !
3314
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25 ✔
3315
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16 ✔
3316
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16 !
UNCOV
3317
                    try {
×
3318
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
UNCOV
3319
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
UNCOV
3320
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
UNCOV
3321
                        this.mergeEvaluateContainers(result, newResult);
×
3322
                    } catch (error) {
UNCOV
3323
                        logger.error('Error getting variables', error);
×
3324
                        this.mergeEvaluateContainers(result, {
×
3325
                            name: result.name,
3326
                            evaluateName: result.evaluateName,
3327
                            children: [],
3328
                            value: `❌ Error: ${error.message}`,
3329
                            type: '',
3330
                            highLevelType: undefined,
3331
                            keyType: undefined
3332
                        });
3333
                    }
3334
                }
3335

3336
                if (result.keyType) {
16 ✔
3337
                    let value = `${result.value ?? result.type}`;
5 !
3338
                    let indexedVariables = result.indexedVariables;
5 ✔
3339
                    let namedVariables = result.namedVariables;
5 ✔
3340

3341
                    if (indexedVariables === undefined || namedVariables === undefined) {
5 !
3342
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3343
                        // by supplying undefined values for both
UNCOV
3344
                        indexedVariables = undefined;
×
UNCOV
3345
                        namedVariables = undefined;
×
3346
                    }
3347

3348
                    // check to see if this is an dictionary or a list
3349
                    if (result.keyType === 'Integer') {
5 !
3350
                        // list type
UNCOV
3351
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3352
                    } else if (result.keyType === 'String') {
5 !
3353
                        // dictionary type
3354
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5 ✔
3355
                    }
3356
                    v.type = result.type;
5 ✔
3357
                } else {
3358

3359
                    let value: string;
3360
                    if (result.type === VariableType.Invalid) {
11 !
UNCOV
3361
                        value = result.value ?? 'Invalid';
×
3362
                    } else if (result.type === VariableType.Uninitialized) {
11 !
UNCOV
3363
                        value = 'Uninitialized';
×
3364
                    } else {
3365
                        value = `${result.value}`;
11 ✔
3366
                    }
3367
                    // If the variable is lazy we must assign a refId to inform the system
3368
                    // to request this variable again in the future for value resolution
3369
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11 !
3370
                }
3371
                this.variables[refId] = v;
16 ✔
3372
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9 !
3373
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9 ✔
3374
                    v = new Variable(result.name, `${result.value}`);
7 ✔
3375
                } else if (result.highLevelType === 'array') {
2 ✔
3376
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1 ✔
3377
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1 !
3378
                    this.variables[refId] = v;
1 ✔
3379
                } else if (result.highLevelType === 'object') {
1 !
3380
                    let refId: number;
3381
                    //handle collections
3382
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1 !
3383
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1 ✔
3384
                    }
3385
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1 !
3386
                    this.variables[refId] = v;
1 ✔
UNCOV
3387
                } else if (result.highLevelType === 'function') {
×
UNCOV
3388
                    v = new Variable(result.name, `${result.value}`);
×
3389
                } else {
3390
                    //all other cases, but mostly for HighLevelType.unknown
UNCOV
3391
                    v = new Variable(result.name, `${result.value}`);
×
3392
                }
3393
            }
3394

3395
            v.type = result.type;
25 ✔
3396
            v.evaluateName = result.evaluateName;
25 ✔
3397
            v.frameId = frameId;
25 ✔
3398
            v.type = result.type;
25 ✔
3399
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25 !
3400
            if (util.isTransientVariable(v.name)) {
25 !
UNCOV
3401
                v.presentationHint = { kind: 'virtual' };
×
3402
            }
3403

3404
            if (result.children && maxDepth > 0) {
25 ✔
3405
                if (!v.childVariables) {
7 !
3406
                    v.childVariables = [];
7 ✔
3407
                }
3408

3409
                // Create a mapping of the children to their index so we can evaluate them in bulk
3410
                let indexMappedChildren = result.children.map((child, index) => {
7 ✔
3411
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10 !
3412
                    return remapped;
10 ✔
3413
                });
3414
                if (this.enableDebugProtocol) {
7 !
UNCOV
3415
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
UNCOV
3416
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
UNCOV
3417
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3418
                    });
3419

3420
                    let variablePathArray = childrenToEvaluate.map(x => {
×
3421
                        return util.getVariablePath(x.child.evaluateName);
×
3422
                    });
3423

UNCOV
3424
                    try {
×
3425
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
3426
                        if (bulkEvaluations.bulkVarName) {
×
UNCOV
3427
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
UNCOV
3428
                            childrenToEvaluate.map((mappedChild, index) => {
×
3429
                                let newResult = newResults.children[index];
×
3430
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
3431
                                mappedChild.child.evaluateNow = false;
×
3432
                                return mappedChild;
×
3433
                            });
3434
                        }
3435
                    } catch (error) {
3436
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3437
                    }
3438
                }
3439
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3440
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7 ✔
3441
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10 ✔
3442
                }));
3443
            } else {
3444
                v.childVariables = [];
18 ✔
3445
            }
3446

3447
            // if the var is an array and debugProtocol is enabled, include the array size
3448
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25 !
UNCOV
3449
                if (isNaN(result.indexedVariables as number)) {
×
UNCOV
3450
                    v.value = v.type;
×
3451
                } else {
UNCOV
3452
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3453
                }
3454
            }
3455
        }
3456
        return v;
25 ✔
3457
    }
3458

3459
    /**
3460
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3461
     * Used primarily for custom variables
3462
     */
3463
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
UNCOV
3464
        original.children = updated.children;
×
UNCOV
3465
        original.value = updated.value;
×
UNCOV
3466
        original.type = updated.type;
×
UNCOV
3467
        original.highLevelType = updated.highLevelType;
×
UNCOV
3468
        original.keyType = updated.keyType;
×
3469
        original.indexedVariables = updated.indexedVariables;
×
3470
        original.namedVariables = updated.namedVariables;
×
3471
    }
3472

3473
    private getEvaluateRefId(expression: string, frameId: number) {
3474
        let evaluateRefId = `${expression}-${frameId}`;
77 ✔
3475
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77 ✔
3476
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45 ✔
3477
        }
3478
        return this.evaluateRefIdLookup[evaluateRefId];
77 ✔
3479
    }
3480

3481
    private clearState() {
3482
        //erase all cached variables
3483
        this.variables = {};
12 ✔
3484
        this.completionParentVariableCache.clear();
12 ✔
3485
    }
3486

3487
    /**
3488
     * Sends a launch progress event to the client if the client supports progress reporting.
3489
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3490
     * - `'update'`: updates the message on the active progress bar.
3491
     * - `'end'`: dismisses the active progress bar with an optional final message.
3492
     */
3493
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3494
        if (!this.initRequestArgs?.supportsProgressReporting) {
78 ✔
3495
            return;
44 ✔
3496
        }
3497
        if (type === 'start') {
34 ✔
3498
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
11 ✔
3499
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
11 ✔
3500
        } else if (this.launchProgressId) {
23 ✔
3501
            if (type === 'update') {
19 ✔
3502
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13 ✔
3503
            } else {
3504
                const lastId = this.launchProgressId;
6 ✔
3505
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
6 ✔
3506
                const endTimer = setTimeout(() => this.flushLaunchProgressEnd?.(), 1000); // add a slight delay before ending the progress to improve UX
6 !
3507
                this.flushLaunchProgressEnd = () => {
6 ✔
3508
                    clearTimeout(endTimer);
6 ✔
3509
                    this.flushLaunchProgressEnd = undefined;
6 ✔
3510
                    this.sendEvent(new ProgressEndEvent(lastId, message));
6 ✔
3511
                };
3512
                this.launchProgressId = undefined;
6 ✔
3513
            }
3514
        }
3515
    }
3516

3517
    /**
3518
     * Tells the client to re-request all variables because we've invalidated them
3519
     * @param threadId
3520
     * @param stackFrameId
3521
     */
3522
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3523
        //if the client supports this request, send it
3524
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3 !
UNCOV
3525
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3526
        }
3527
    }
3528

3529
    /**
3530
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3531
     */
3532
    public async handleEntryBreakpoint() {
3533
        if (!this.enableDebugProtocol) {
4 !
3534
            this.entryBreakpointWasHandled = true;
4 ✔
3535
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4 ✔
3536
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1 ✔
3537
            }
3538
        }
3539
    }
3540

3541
    /**
3542
     * Converts a debugger line number to a client line number.
3543
     *
3544
     * @param debuggerLine - The line number from the debugger as zero based.
3545
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3546
     * @returns The corresponding client line number.
3547
     */
3548
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3549
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3 !
3550
    }
3551

3552
    /**
3553
     * Converts a debugger column number to a client column number.
3554
     *
3555
     * @param debuggerLine - The column number from the debugger as zero based.
3556
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3557
     * @returns The corresponding client column number.
3558
     */
3559
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3560
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3 !
3561
    }
3562

3563
    /**
3564
     * Converts a client line number to a debugger line number.
3565
     *
3566
     * @param clientLine - The line number from the client.
3567
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3568
     * @returns The corresponding debugger line number as zero based.
3569
     */
3570
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3571
        if (typeof clientLine === 'number') {
109 ✔
3572
            return this.convertClientLineToDebugger(clientLine);
2 ✔
3573
        }
3574
        return defaultDebuggerLine;
107 ✔
3575
    }
3576

3577
    /**
3578
     * Converts a client column number to a debugger column number.
3579
     *
3580
     * @param clientLine - The column number from the client.
3581
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3582
     * @returns The corresponding debugger column number as zero based.
3583
     */
3584
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3585
        if (typeof clientLine === 'number') {
89 !
3586
            return this.convertClientColumnToDebugger(clientLine);
89 ✔
3587
        }
UNCOV
3588
        return defaultDebuggerLine;
×
3589
    }
3590

3591
    private shutdownPromise: Promise<void> | undefined = undefined;
229 ✔
3592

3593
    /**
3594
     * 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
3595
     * the same promise on subsequent calls
3596
     */
3597
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
17 ✔
3598
        if (this.shutdownPromise === undefined) {
17 !
3599
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
17 ✔
3600
            //Backstop: if the graceful shutdown hangs (e.g. a home key press against an unreachable
3601
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3602
            const forceExitTimer = setTimeout(() => {
17 ✔
UNCOV
3603
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
UNCOV
3604
                this.forceExit();
×
3605
            }, this.shutdownForceExitTimeout);
3606
            forceExitTimer.unref?.();
17 !
3607
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
17 ✔
3608
                clearTimeout(forceExitTimer);
17 ✔
3609
            });
3610
        } else {
UNCOV
3611
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3612
        }
3613
        return this.shutdownPromise;
17 ✔
3614
    }
3615

3616
    private async _shutdown(errorMessage?: string, modal = false): Promise<void> {
×
3617
        // Ensure any active launch progress bar is dismissed before showing error messages or the terminated event.
3618
        this.sendLaunchProgress('end', 'Complete');
17 ✔
3619
        // 'end' defers its ProgressEndEvent for UX; deliver it right now (whether from the line above or from an
3620
        // earlier 'end' whose delay has not elapsed yet) - the adapter exits before a pending timer would fire,
3621
        // which would leave the client's progress notification stuck open
3622
        this.flushLaunchProgressEnd?.();
17 ✔
3623

3624
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3625
        try {
17 ✔
3626
            if (errorMessage) {
17 !
UNCOV
3627
                this.logger.error(errorMessage);
×
UNCOV
3628
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
UNCOV
3629
                    this.logger.error('Error showing popup message', { error });
×
3630
                });
3631
            }
3632
        } catch (e) {
3633
            this.logger.error(e);
×
3634
        }
3635
        // stop perfetto tracing if it's running
3636
        try {
17 ✔
3637
            await this.perfettoManager?.stopTracing?.();
17 !
3638
        } catch (e) {
UNCOV
3639
            this.logger.error('Error stopping perfetto tracing', e);
×
3640
        }
3641

3642
        try {
17 ✔
3643
            await this.perfettoManager?.dispose?.();
17 !
3644
        } catch (e) {
UNCOV
3645
            this.logger.error('Error disposing perfetto manager', e);
×
3646
        }
3647

3648
        //close the debugger connection
3649
        try {
17 ✔
3650
            this.logger.log('Destroy rokuAdapter');
17 ✔
3651
            await this.rokuAdapter?.destroy?.();
17 !
3652
            //press the home button to return to the home screen
3653
            try {
17 ✔
3654
                this.logger.log('Press home button');
17 ✔
3655
                await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
17 ✔
3656
            } catch (e) {
UNCOV
3657
                this.logger.error(e);
×
3658
            }
3659
        } catch (e) {
UNCOV
3660
            this.logger.error(e);
×
3661
        }
3662

3663
        try {
17 ✔
3664
            this.projectManager?.dispose?.();
17 !
3665
        } catch (e) {
UNCOV
3666
            this.logger.error(e);
×
3667
        }
3668

3669
        try {
17 ✔
3670
            this.componentLibraryServer?.stop();
17 !
3671
        } catch (e) {
UNCOV
3672
            this.logger.error(e);
×
3673
        }
3674

3675
        try {
17 ✔
3676
            await this.rendezvousTracker?.destroy?.();
17 !
3677
        } catch (e) {
UNCOV
3678
            this.logger.error(e);
×
3679
        }
3680

3681
        try {
17 ✔
3682
            await this.sourceMapManager?.destroy?.();
17 !
3683
        } catch (e) {
UNCOV
3684
            this.logger.error(e);
×
3685
        }
3686

3687
        try {
17 ✔
3688
            //if configured, delete the staging directory
3689
            if (!this.launchConfiguration.retainStagingFolder) {
17 !
3690
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
17 !
3691
                this.logger.info('deleting staging folders', stagingDirs);
17 ✔
3692
                for (let stagingDir of stagingDirs) {
17 ✔
3693
                    try {
2 ✔
3694
                        fsExtra.removeSync(stagingDir);
2 ✔
3695
                    } catch (e) {
UNCOV
3696
                        this.logger.error(e);
×
UNCOV
3697
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3698
                    }
3699
                }
3700
            }
3701
        } catch (e) {
3702
            this.logger.error(e);
×
3703
        }
3704

3705
        try {
17 ✔
3706
            this.logger.log('Send terminated event');
17 ✔
3707
            this.sendEvent(new TerminatedEvent());
17 ✔
3708

3709
            //shut down the process
3710
            this.logger.log('super.shutdown()');
17 ✔
3711
            super.shutdown();
17 ✔
3712
            this.logger.log('shutdown complete');
17 ✔
3713
        } catch (e) {
UNCOV
3714
            this.logger.error(e);
×
3715
        }
3716

3717
        try {
17 ✔
3718
            this.teardownProcessErrorHandlers();
17 ✔
3719
        } catch (e) {
UNCOV
3720
            this.logger.error(e);
×
3721
        }
3722
    }
3723
}
3724

3725
export interface AugmentedVariable extends DebugProtocol.Variable {
3726
    childVariables?: AugmentedVariable[];
3727
    // eslint-disable-next-line camelcase
3728
    request_seq?: number;
3729
    frameId?: number;
3730
    /**
3731
     * only used for lazy variables
3732
     */
3733
    isResolved?: boolean;
3734
    /**
3735
     * used to indicate that this variable is a scope variable
3736
     * and may require special handling
3737
     */
3738
    isScope?: boolean;
3739
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc