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

rokucommunity / vscode-brightscript-language / 30096694707

24 Jul 2026 01:23PM UTC coverage: 59.949% (+0.04%) from 59.908%
30096694707

Pull #802

github

web-flow
Merge 333fa7808 into e465804e3
Pull Request #802: Route extension logs through the BrightScript Extension output channel

2592 of 4745 branches covered (54.63%)

Branch coverage included in aggregate %.

19 of 23 new or added lines in 3 files covered. (82.61%)

732 existing lines in 18 files now uncovered.

4036 of 6311 relevant lines covered (63.95%)

43.79 hits per line

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

74.29
/src/DebugConfigurationProvider.ts
1
import { util as bslangUtil } from 'brighterscript';
1✔
2
import * as dotenv from 'dotenv';
1✔
3
import * as path from 'path';
1✔
4
import * as fsExtra from 'fs-extra';
1✔
5
import * as rta from 'roku-test-automation';
1✔
6
import type {
7
    CancellationToken,
8
    DebugConfigurationProvider,
9
    ExtensionContext,
10
    WorkspaceFolder
11
} from 'vscode';
12
import * as vscode from 'vscode';
1✔
13
import type { LaunchConfiguration } from 'roku-debug';
14
import { fileUtils } from 'roku-debug';
1✔
15
import { util } from './util';
1✔
16
import type { TelemetryManager } from './managers/TelemetryManager';
17
// eslint-disable-next-line @typescript-eslint/no-require-imports
18
import cloneDeep = require('clone-deep');
1✔
19
import { rokuDeploy } from 'roku-deploy';
1✔
20
import type { DeviceInfo } from 'roku-deploy';
21
import type { UserInputManager } from './managers/UserInputManager';
22
import type { BrightScriptCommands } from './BrightScriptCommands';
23
import type { RokuProjectManager } from './managers/RokuProject/RokuProjectManager';
24
import type { DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager';
25
import type { CredentialStore } from './managers/CredentialStore';
26

27

28
export class BrightScriptDebugConfigurationProvider implements DebugConfigurationProvider {
1✔
29

30
    /**
31
     * Canonical default `files` glob list for a Roku channel launch configuration.
32
     * Other providers (e.g. ManifestProjectProvider) reference this so the project's
33
     * notion of "what to deploy" stays in one place.
34
     *
35
     * Backed by Roku's documented channel package layout:
36
     *   - manifest, source/, components/, images/ — listed as standard folders in
37
     *     https://developer.roku.com/dev/docs/hello-world
38
     *   - locale/ — auto-loaded for translations and localized images per
39
     *     https://developer.roku.com/dev/docs/localization
40
     *   - fonts/ — TrueType/OpenType files loaded via roFontRegistry
41
     *   - componentLibraries/ — output dir for component-library builds bundled with the channel
42
     */
43
    public static readonly defaultFiles: ReadonlyArray<string> = [
1✔
44
        'source/**/*.*',
45
        'componentLibraries/**/*.*',
46
        'components/**/*.*',
47
        'images/**/*.*',
48
        'locale/**/*',
49
        'fonts/**/*',
50
        'manifest'
51
    ];
52

53
    public constructor(
54
        private context: ExtensionContext,
71✔
55
        private telemetryManager: TelemetryManager,
71✔
56
        private extensionOutputChannel: vscode.OutputChannel,
71✔
57
        private userInputManager: UserInputManager,
71✔
58
        private brightScriptCommands: BrightScriptCommands,
71✔
59
        private deviceManager: DeviceManager,
71✔
60
        private credentialStore: CredentialStore,
71✔
61
        private rokuProjectDiscovery?: RokuProjectManager
71✔
62
    ) {
63
        this.context = context;
71✔
64
    }
65

66
    //make unit testing easier by adding these imports properties
67
    public fsExtra = fsExtra;
71✔
68
    public util = util;
71✔
69

70
    private configDefaults: Partial<BrightScriptLaunchConfiguration> = {
71✔
71
        type: 'brightscript',
72
        name: 'BrightScript Debug: Launch',
73
        host: '${promptForHost}',
74
        password: '${promptForPassword}',
75
        consoleOutput: 'normal',
76
        request: 'launch',
77
        stopOnEntry: false,
78
        outDir: '${workspaceFolder}/out/',
79
        retainDeploymentArchive: true,
80
        injectRaleTrackerTask: false,
81
        injectRdbOnDeviceComponent: false,
82
        disableScreenSaver: true,
83
        retainStagingFolder: false,
84
        enableVariablesPanel: true,
85
        deferScopeLoading: false,
86
        autoResolveVirtualVariables: false,
87
        enhanceREPLCompletions: true,
88
        showHiddenVariables: false,
89
        enableDebuggerAutoRecovery: false,
90
        stopDebuggerOnAppExit: false,
91
        autoRunSgDebugCommands: [],
92
        files: [...BrightScriptDebugConfigurationProvider.defaultFiles],
93
        enableSourceMaps: true,
94
        rewriteDevicePathsInLogs: true,
95
        packagePort: 80,
96
        enableDebugProtocol: true,
97
        remotePort: 8060,
98
        rendezvousTracking: true,
99
        deleteDevChannelBeforeInstall: false,
100
        sceneGraphDebugCommandsPort: 8080,
101
        componentLibrariesPort: 8080,
102
        remoteControlMode: {
103
            activateOnSessionStart: false,
104
            deactivateOnSessionEnd: false
105
        }
106
    };
107

108
    public async provideDebugConfigurations(folder?: WorkspaceFolder, _token?: CancellationToken): Promise<vscode.DebugConfiguration[]> {
UNCOV
109
        return (await this.rokuProjectDiscovery?.provideDebugConfigurations(folder)) ?? [];
×
110
    }
111

112
    /**
113
     * Massage a debug configuration just before a debug session is being launched,
114
     * e.g. add all missing attributes to the debug configuration.
115
     */
116
    public async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration, token?: CancellationToken): Promise<BrightScriptLaunchConfiguration> {
117
        // F5 with no launch.json — ask RokuProjectDiscovery to find a config from the active file.
118
        if (!config.type && !config.request) {
15✔
119
            const discovered = await this.rokuProjectDiscovery?.resolveDebugConfigFromActiveFile();
3✔
120
            if (!discovered) {
3✔
121
                return undefined;
2✔
122
            }
123
            config = discovered as BrightScriptLaunchConfiguration;
1✔
124
        }
125

126
        let deviceInfo: DeviceInfo;
127
        let result: BrightScriptLaunchConfiguration;
128
        try {
13✔
129
            // merge user and workspace settings into the config
130
            result = this.processUserWorkspaceSettings(config);
13✔
131

132
            //force a specific stagingDir because sometimes this conflicts with bsconfig.json
133
            result.stagingDir ??= path.join('${outDir}/.roku-deploy-staging');
13!
134
            result.stagingFolderPath = result.stagingDir;
13✔
135

136
            result = await this.sanitizeConfiguration(result, folder);
13✔
137
            result = await this.processEnvVariables(folder, result);
13✔
138
            result = await this.processHostParameter(result);
13✔
139
            result = await this.processPasswordParameter(config, result);
12✔
140
            result = await this.processDeepLinkUrlParameter(result);
12✔
141
            result = await this.processLogfilePath(folder, result);
12✔
142
            result = this.processDapLogFilePath(folder, result);
12✔
143

144
            // `processHostParameter` attached the raw device-info it gathered while probing the host.
145
            // Enhance a local copy here for the developer-mode check + telemetry (no request to the device).
146
            if (result.deviceInfo) {
12!
147
                deviceInfo = rokuDeploy.enhanceDeviceInfo(result.deviceInfo);
12✔
148
            }
149

150
            if (deviceInfo && !deviceInfo.developerEnabled) {
12✔
151
                throw new Error(`Cannot deploy: developer mode is disabled on '${result.host}'`);
1✔
152
            }
153
            await this.context.workspaceState.update('enableDebuggerAutoRecovery', result.enableDebuggerAutoRecovery);
11✔
154

155
            //advertise the optional features this client supports to the debug adapter. This is populated by the
156
            //extension itself (not a user-facing setting) and gates optional roku-debug behavior like the
157
            //`processStagingDir` reverse request.
158
            result.clientCapabilities = {
11✔
159
                supportsProcessStagingDir: true
160
            };
161

162
            return result;
11✔
163
        } catch (e) {
164
            //log any exceptions to the extension panel
165
            this.extensionOutputChannel.append((e as Error).stack);
2✔
166
            throw e;
2✔
167
        } finally {
168
            //send telemetry about this debug session (don't worry, it gets sanitized...we're just checking if certain features are being used)
169
            this.telemetryManager?.sendStartDebugSessionEvent(
13!
170
                this.processUserWorkspaceSettings(config) as any,
171
                result,
172
                deviceInfo
173
            );
174
        }
175
    }
176

177
    /**
178
     * There are several debug-level config values that can be stored in user settings, so get those
179
     */
180
    private processUserWorkspaceSettings(config: BrightScriptLaunchConfiguration): BrightScriptLaunchConfiguration {
181
        const workspaceConfig = util.getConfiguration('brightscript.debug');
13✔
182

183
        let userWorkspaceSettings = {} as BrightScriptLaunchConfiguration;
13✔
184

185
        //only keep the config values that were explicitly defined in a config file (i.e. exclude default values)
186
        for (const key of Object.keys(workspaceConfig)) {
13✔
187
            const inspection = workspaceConfig.inspect(key);
39✔
188
            //if the value was explicitly defined by the user in one of the various locations, then keep this value
189
            if (
39!
190
                inspection.globalValue !== undefined ||
273✔
191
                inspection.workspaceValue !== undefined ||
192
                inspection.globalLanguageValue !== undefined ||
193
                inspection.defaultLanguageValue !== undefined ||
194
                inspection.workspaceFolderValue !== undefined ||
195
                inspection.workspaceLanguageValue !== undefined ||
196
                inspection.workspaceFolderLanguageValue !== undefined
197
            ) {
UNCOV
198
                userWorkspaceSettings[key] = workspaceConfig[key];
×
199
            }
200
        }
201

202
        //merge the user/workspace settings in with the config (the config wins on conflict)
203
        const result = {
13✔
204
            ...userWorkspaceSettings ?? {},
39!
205
            ...cloneDeep(config ?? {})
39!
206
        };
207
        return result as BrightScriptLaunchConfiguration;
13✔
208
    }
209

210
    /**
211
     * Takes the launch.json config and applies any defaults to missing values and sanitizes some of the more complex options
212
     * @param config current config object
213
     */
214
    private async sanitizeConfiguration(config: BrightScriptLaunchConfiguration, folder: WorkspaceFolder): Promise<BrightScriptLaunchConfiguration> {
215
        let userWorkspaceSettings: any = util.getConfiguration('brightscript') || {};
13!
216

217
        //make sure we have an object
218
        config = {
13✔
219

220
            //the workspace settings are the baseline
221
            ...userWorkspaceSettings,
222
            //override with any debug-specific settings
223
            ...config
224
        };
225

226
        let folderUri: vscode.Uri;
227
        //use the workspace folder provided
228
        if (folder) {
13!
229
            folderUri = folder.uri;
13✔
230

231
            //if there's only one workspace, use that workspace's folder path
UNCOV
232
        } else if (vscode.workspace.workspaceFolders?.length === 1) {
×
UNCOV
233
            folderUri = vscode.workspace.workspaceFolders[0].uri;
×
234
        } else {
235
            //there are multiple workspaces, ask the user to specify which one they want to use
236
            let workspaceFolder = await vscode.window.showWorkspaceFolderPick();
×
UNCOV
237
            if (workspaceFolder) {
×
UNCOV
238
                folderUri = workspaceFolder.uri;
×
239
            }
240
        }
241

242
        if (!folderUri) {
13!
243
            //cancel this whole thing because we can't continue without the user specifying a workspace folder
UNCOV
244
            throw new Error('Cannot determine which workspace to use for brightscript debugging');
×
245
        }
246

247
        //load the bsconfig settings (if available)
248
        let bsconfig = this.getBsConfig(folderUri);
13✔
249
        if (bsconfig) {
13✔
250
            config = { ...bsconfig, ...config };
7✔
251
        }
252

253
        config.cwd = folderUri.fsPath;
13✔
254

255
        config.rootDir = this.util.ensureTrailingSlash(config.rootDir ? config.rootDir : '${workspaceFolder}');
13✔
256

257
        //Check for depreciated Items
258
        if (config.debugRootDir) {
13!
UNCOV
259
            if (config.sourceDirs) {
×
UNCOV
260
                throw new Error('Cannot set both debugRootDir AND sourceDirs');
×
261
            } else {
262
                config.sourceDirs = [this.util.ensureTrailingSlash(config.debugRootDir)];
×
263
            }
264
        } else if (config.sourceDirs) {
13!
265
            let dirs: string[] = [];
×
266

UNCOV
267
            for (let dir of config.sourceDirs) {
×
268
                dirs.push(this.util.ensureTrailingSlash(dir));
×
269
            }
270
            config.sourceDirs = dirs;
×
271
        } else if (!config.sourceDirs) {
13!
272
            config.sourceDirs = [];
13✔
273
        }
274

275
        if (config.componentLibraries) {
13!
UNCOV
276
            config.componentLibrariesOutDir = this.util.ensureTrailingSlash(config.componentLibrariesOutDir ? config.componentLibrariesOutDir : '${workspaceFolder}/libs');
×
277

UNCOV
278
            for (let library of config.componentLibraries as any) {
×
279
                library.rootDir = this.util.ensureTrailingSlash(library.rootDir);
×
UNCOV
280
                library.files = library.files ? library.files : [...BrightScriptDebugConfigurationProvider.defaultFiles];
×
281
            }
282
        } else {
283
            //create an empty array so it's easier to reason with downstream
284
            config.componentLibraries = [];
13✔
285
        }
286

287
        // Apply any defaults to missing values
288
        for (const key in this.configDefaults) {
13✔
289
            config[key] ??= this.configDefaults[key];
416✔
290
        }
291

292
        // Run any required post processing after applying defaults
293
        config.outDir = this.util.ensureTrailingSlash(config.outDir);
13✔
294

295
        // Pass along files needed by RDB to roku-debug
296
        config.rdbFilesBasePath = rta.utils.getDeviceFilesPath();
13✔
297

298
        //if packageTask is defined, make sure there's actually a task with that name defined
299
        if (config.packageTask) {
13!
UNCOV
300
            const targetTask = (await vscode.tasks.fetchTasks()).find(x => x.name === config.packageTask);
×
UNCOV
301
            if (!targetTask) {
×
UNCOV
302
                throw new Error(`Cannot find task '${config.packageTask}' for launch option 'packageTask'`);
×
303
            }
304
        }
305

306
        if (typeof config.remoteControlMode === 'boolean') {
13✔
307
            config.remoteControlMode = {
2✔
308
                activateOnSessionStart: config.remoteControlMode,
309
                deactivateOnSessionEnd: config.remoteControlMode
310
            };
311
        } else {
312
            config.remoteControlMode = {
11✔
313
                activateOnSessionStart: config.remoteControlMode?.activateOnSessionStart ?? this.configDefaults.remoteControlMode.activateOnSessionStart,
66!
314
                deactivateOnSessionEnd: config.remoteControlMode?.deactivateOnSessionEnd ?? this.configDefaults.remoteControlMode.deactivateOnSessionEnd
66!
315
            };
316
        }
317

318
        if (config.request !== 'launch') {
13!
UNCOV
319
            await vscode.window.showErrorMessage(`roku-debug only supports the 'launch' request type`);
×
320
        }
321

322
        if (config.raleTrackerTaskFileLocation?.includes('${workspaceFolder}')) {
13!
UNCOV
323
            config.raleTrackerTaskFileLocation = path.normalize(config.raleTrackerTaskFileLocation.replace('${workspaceFolder}', folderUri.fsPath));
×
324
        }
325

326
        // Check for the existence of the tracker task file in auto injection is enabled
327
        if (config.injectRaleTrackerTask) {
13!
UNCOV
328
            if (!config.raleTrackerTaskFileLocation) {
×
UNCOV
329
                await vscode.window.showErrorMessage(`"raleTrackerTaskFileLocation" must be defined when "injectRaleTrackerTask" is enabled`);
×
UNCOV
330
            } else if (await this.util.fileExists(config.raleTrackerTaskFileLocation) === false) {
×
331
                await vscode.window.showErrorMessage(`injectRaleTrackerTask was set to true but could not find TrackerTask.xml at:\n${config.raleTrackerTaskFileLocation}`);
×
332
            }
333
        }
334

335
        //for rootDir, replace workspaceFolder now to avoid issues in vscode itself
336
        if (config.rootDir.includes('${workspaceFolder}')) {
13✔
337
            config.rootDir = path.normalize(config.rootDir.replace('${workspaceFolder}', folderUri.fsPath));
12✔
338
        }
339

340
        //for outDir, replace workspaceFolder now
341
        if (config.outDir.includes('${workspaceFolder}')) {
13!
342
            config.outDir = path.normalize(config.outDir.replace('${workspaceFolder}', folderUri.fsPath));
13✔
343
        }
344

345
        if (config.stagingFolderPath.includes('${outDir}')) {
13!
346
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${outDir}', config.outDir));
13✔
347
        }
348
        if (config.stagingFolderPath.includes('${workspaceFolder}')) {
13!
UNCOV
349
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${workspaceFolder}', folderUri.fsPath));
×
350
        }
351

352
        if (config.stagingDir.includes('${outDir}')) {
13!
353
            config.stagingDir = path.normalize(config.stagingDir.replace('${outDir}', config.outDir));
13✔
354
        }
355
        if (config.stagingDir.includes('${workspaceFolder}')) {
13!
UNCOV
356
            config.stagingDir = path.normalize(config.stagingDir.replace('${workspaceFolder}', folderUri.fsPath));
×
357
        }
358

359

360
        // Make sure that directory paths end in a trailing slash
361
        if (config.debugRootDir) {
13!
UNCOV
362
            config.debugRootDir = this.util.ensureTrailingSlash(config.debugRootDir);
×
363
        }
364

365
        if (config.packagePath?.includes('${workspaceFolder}')) {
13!
UNCOV
366
            config.packagePath = path.normalize(config.packagePath.replace('${workspaceFolder}', folderUri.fsPath));
×
367
        }
368
        if (config.packagePath?.includes('${outDir}')) {
13!
369
            config.packagePath = path.normalize(config.packagePath.replace('${outDir}', config.outDir));
×
370
        }
371

372
        if (!config.rootDir) {
13!
UNCOV
373
            console.log('No rootDir specified: defaulting to ${workspaceFolder}');
×
374
            //use the current workspace folder
UNCOV
375
            config.rootDir = folderUri.fsPath;
×
376
        }
377

378
        return config;
13✔
379
    }
380

381
    public async processLogfilePath(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration) {
382
        if (config?.logfilePath?.trim()) {
22✔
383
            config.logfilePath = config.logfilePath.trim();
5✔
384
            if (config.logfilePath.includes('${workspaceFolder}')) {
5✔
385
                config.logfilePath = config.logfilePath.replace('${workspaceFolder}', folder.uri.fsPath);
1✔
386
            }
387

388
            try {
5✔
389
                config.logfilePath = fileUtils.standardizePath(config.logfilePath);
5✔
390
                //create the logfile folder structure if not exist
391
                fsExtra.ensureDirSync(path.dirname(config.logfilePath));
5✔
392

393
                //create the log file if it doesn't exist
394
                if (!fsExtra.pathExistsSync(config.logfilePath)) {
5✔
395
                    fsExtra.createFileSync(config.logfilePath);
4✔
396
                }
397
                await this.context.workspaceState.update('logfilePath', config.logfilePath);
5✔
398
            } catch (e) {
UNCOV
399
                throw new Error(`Could not create logfile at "${config.logfilePath}"`);
×
400
            }
401
        }
402
        return config;
22✔
403
    }
404

405
    public processDapLogFilePath(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration) {
406
        if (!config.debugAdapterProtocolLogging) {
18✔
407
            return config;
14✔
408
        }
409
        const folderPath = folder?.uri.fsPath ?? process.cwd();
4!
410
        const dir = path.resolve(folderPath, './logs');
4✔
411
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
4✔
412
        fsExtra.ensureDirSync(dir);
4✔
413
        config.debugAdapterProtocolLogFilePath = path.join(dir, `${timestamp}-debugAdapterProtocol.log`);
4✔
414
        return config;
4✔
415
    }
416

417
    /**
418
     * Resolves any `${env:*}` placeholders in the config using the process environment
419
     * and (if present) the optional `.env` file, without mutating `process.env`.
420
     * @param folder current workspace folder
421
     * @param config current config object
422
     */
423
    private async processEnvVariables(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration): Promise<BrightScriptLaunchConfiguration> {
424
        //start with a copy of the current process environment so we never mutate process.env
425
        let environmentValues = { ...process.env };
24✔
426
        let loadedEnvFile = false;
24✔
427

428
        //layer any values from the .env file on top of the process environment (the .env file is optional)
429
        if (config.envFile) {
24✔
430
            let envFilePath = config.envFile;
13✔
431
            //resolve ${workspaceFolder} so we can actually load the .env file now
432
            if (config.envFile.includes('${workspaceFolder}')) {
13✔
433
                envFilePath = config.envFile.replace('${workspaceFolder}', folder.uri.fsPath);
11✔
434
            }
435
            if (await this.util.fileExists(envFilePath) === false) {
13✔
436
                //the .env file is optional, so just warn instead of failing the debug session
437
                console.warn(`Cannot find .env file at "${envFilePath}". Falling back to the process environment for '\${env:*}' values.`);
4✔
438
            } else {
439
                //parse the .env file, letting its values override the process environment
440
                environmentValues = {
9✔
441
                    ...environmentValues,
442
                    ...dotenv.parse(await this.fsExtra.readFile(envFilePath))
443
                };
444
                loadedEnvFile = true;
9✔
445
            }
446
        }
447

448
        // temporarily convert entire config to string for any environment replacements.
449
        let configString = JSON.stringify(config);
24✔
450
        let match: RegExpMatchArray;
451
        let regexp = /\$\{env:([\w\d_]*)\}/g;
24✔
452
        let updatedConfigString = configString;
24✔
453

454
        // apply any defined values to env placeholders
455
        while ((match = regexp.exec(configString))) {
24✔
456
            let environmentVariableName = match[1];
14✔
457
            let environmentVariableValue = environmentValues[environmentVariableName];
14✔
458

459
            if (environmentVariableValue) {
14✔
460
                updatedConfigString = updatedConfigString.replace(match[0], environmentVariableValue);
11✔
461
            }
462
        }
463

464
        config = JSON.parse(updatedConfigString);
24✔
465

466
        let configDefaults = {
24✔
467
            rootDir: config.rootDir,
468
            ...this.configDefaults
469
        };
470

471
        // apply any default values to env placeholders
472
        for (let key in config) {
24✔
473
            let configValue = config[key];
530✔
474
            let match: RegExpMatchArray;
475
            //replace all environment variable placeholders with their values
476
            while ((match = regexp.exec(configValue))) {
530✔
477
                let environmentVariableName = match[1];
3✔
478
                configValue = configDefaults[key];
3✔
479
                console.log(`The configuration value for ${key} was not found in the environment variables${loadedEnvFile ? ' or env file' : ''} under the name ${environmentVariableName}. Defaulting the value to: ${configValue}`);
3✔
480
            }
481
            config[key] = configValue;
530✔
482
        }
483
        return config;
24✔
484
    }
485

486
    /**
487
     * Validates the host parameter in the config and opens an input ui if set to ${promptForHost}.
488
     * ${activeHost} is a deprecated alias for ${promptForHost}.
489
     * Both use the active device when it's set and passes a health check, otherwise fall back to the device picker.
490
     *
491
     * Assigns the raw `device-info` gathered while probing the resolved host onto `config.deviceInfo`,
492
     * so downstream password resolution and the debug session can reuse it without re-fetching.
493
     * Throws if the device couldn't be reached (no device-info came back).
494
     * @param config  current config object
495
     */
496
    private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise<BrightScriptLaunchConfiguration> {
497
        const trimmedHost = config.host.trim();
19✔
498
        const needsHostPrompt =
499
            trimmedHost === '' ||
19✔
500
            trimmedHost === '${promptForHost}' ||
501
            trimmedHost === '${activeHost}' ||
502
            config?.deepLinkUrl?.includes('${promptForHost}');
90!
503

504
        let device: RokuDevice | undefined;
505

506
        if (needsHostPrompt) {
19✔
507
            // both the active-host lookup and the picker probe + register the device in the device
508
            // manager, so reuse it below instead of probing again
509
            let resolved = await this.brightScriptCommands.getHealthyActiveHost();
4✔
510
            if (!resolved) {
4✔
511
                resolved = await this.userInputManager.promptForHost();
2✔
512
                if (resolved?.host) {
2!
513
                    //the active device (if there was one) couldn't be located and the user picked a device
514
                    //themselves, so forget the saved active device unless they picked that same device
515
                    await this.deviceManager.forgetActiveDeviceIfDifferent(resolved.host);
2✔
516
                }
517
            }
518
            config.host = resolved?.host;
4!
519
            if (resolved?.host) {
4!
520
                device = this.deviceManager.getDevice({ ip: resolved.host });
4✔
521
            }
522
        }
523

524
        //check the host and throw error if not provided or update the workspace to set last host
525
        if (!config.host) {
19!
UNCOV
526
            throw new Error('Debug session terminated: host is required.');
×
527
        } else {
528
            await this.context.workspaceState.update('remoteHost', config.host);
19✔
529
        }
530

531
        // If the host didn't come from the picker, probe it so we have fresh SN/deviceInfo.
532
        device ??= await this.deviceManager.validateAndAddDevice(config.host);
19✔
533

534
        // A reachable developer device always returns device-info; its absence means we couldn't reach it.
535
        if (!device?.deviceInfo || Object.keys(device.deviceInfo).length === 0) {
19!
536
            throw new Error(`Debug session terminated: unable to reach device at '${config.host}'.`);
2✔
537
        }
538

539
        // Attach the raw device-info so downstream password resolution and the debug session can reuse it
540
        // without another request to the device.
541
        config.deviceInfo = device.deviceInfo;
17✔
542

543
        return config;
17✔
544
    }
545

546
    /**
547
     * Resolve the device password for the launch configuration.
548
     *
549
     * Collects candidate passwords from every known source (cred store, matching
550
     * `brightscript.devices[]` entry across scopes, `brightscript.defaultDevicePassword`,
551
     * the merged `result.password`, and the raw `config.password`), dedupes them,
552
     * validates each against the device in order, and uses the first that is
553
     * accepted. The winning password is cached in the cred store so later launches
554
     * resolve without re-validating every candidate. If no candidate is accepted,
555
     * the user is prompted.
556
     *
557
     * @param config  the raw launch configuration as received from VS Code
558
     * @param result  the merged/resolved config being built up. Its `deviceInfo` (set by
559
     *                `processHostParameter`) supplies the serial number used to look up credentials.
560
     */
561
    private async processPasswordParameter(
562
        config: BrightScriptLaunchConfiguration,
563
        result: BrightScriptLaunchConfiguration
564
    ): Promise<BrightScriptLaunchConfiguration> {
565
        const host = result.host;
30✔
566
        const serialNumber = result.deviceInfo?.['serial-number'];
30✔
567

568
        // Opportunistically drain any legacy IP-keyed password that still lives in
569
        // workspaceState from pre-refactor extension installs. Reads never consult
570
        // this store anymore; it's peeked here and disposed of once we know whether
571
        // it works. This step is best-effort: an unreachable device just means we
572
        // try again next launch; the authoritative error surfaces from the main flow.
573
        const legacyPassword = this.getLegacyIpKeyedPassword(host);
30✔
574
        if (legacyPassword !== undefined) {
30✔
575
            const validation = await this.deviceManager.validateDevicePassword(host, legacyPassword);
4✔
576
            if (validation === 'ok') {
4✔
577
                await this.clearLegacyIpKeyedPassword(host);
2✔
578
                // A legacy entry is explicit historical opt-in: persist it to the cred store
579
                // (unconditionally, unlike the normal "refresh existing only" gate) and the
580
                // global fallback, then use it.
581
                if (serialNumber) {
2!
582
                    await this.credentialStore.setPassword(serialNumber, legacyPassword);
2✔
583
                }
584
                result.password = legacyPassword;
2✔
585
                await this.context.workspaceState.update('remotePassword', legacyPassword);
2✔
586
                return result;
2✔
587
            } else if (validation === 'bad-password') {
2✔
588
                // Reads don't use the legacy store anymore, so a proven-wrong entry is dead weight.
589
                await this.clearLegacyIpKeyedPassword(host);
1✔
590
            }
591
            // 'unreachable' — leave the legacy entry alone (it may still be correct) and
592
            // fall through to the normal candidate flow, which will surface its own error.
593
        }
594

595
        // Resolve and validate the password against the device, trying the launch config's own
596
        // `result.password`/`config.password` after the standard credential sources. The resolver
597
        // prompts (and persists an accepted password) the same way it does for the other commands.
598
        const resolution = await this.userInputManager.resolveDevicePassword({
28✔
599
            host: host,
600
            serialNumber: serialNumber,
601
            extraCandidates: [result.password, config.password]
602
        });
603
        if (resolution.status === 'unreachable') {
28✔
604
            throw new Error(`Debug session terminated: device at ${host} is unreachable.`);
1✔
605
        }
606
        if (resolution.status === 'cancelled') {
27✔
607
            throw new Error('Debug session terminated: password is required.');
2✔
608
        }
609
        result.password = resolution.password;
25✔
610
        // Keep the global password fallback in sync so later launches resolve without re-prompting.
611
        await this.context.workspaceState.update('remotePassword', resolution.password);
25✔
612
        return result;
25✔
613
    }
614

615
    private readonly legacyPasswordStoreKey = 'devicePasswords';
71✔
616

617
    /**
618
     * Peek the legacy IP-keyed password store (`workspaceState.devicePasswords`).
619
     * Reads never use this store anymore; it's consulted only for one-shot
620
     * migration inside `processPasswordParameter` and drained on the first
621
     * successful device contact.
622
     */
623
    private getLegacyIpKeyedPassword(ip: string): string | undefined {
624
        if (!ip) {
30!
UNCOV
625
            return undefined;
×
626
        }
627
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
30✔
628
        return map[ip];
30✔
629
    }
630

631
    /**
632
     * Remove an entry from the legacy IP-keyed password store.
633
     */
634
    private async clearLegacyIpKeyedPassword(ip: string): Promise<void> {
635
        if (!ip) {
3!
UNCOV
636
            return;
×
637
        }
638
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
3!
639
        if (!(ip in map)) {
3!
UNCOV
640
            return;
×
641
        }
642
        delete map[ip];
3✔
643
        await this.context.workspaceState.update(this.legacyPasswordStoreKey, map);
3✔
644
    }
645

646
    /**
647
     * Validates the deepLinkUrl parameter in the config and opens an input ui if set to ${promptForDeepLinkUrl} or if the url contains ${promptForQueryParams
648
     * @param config  current config object
649
     */
650
    private async processDeepLinkUrlParameter(config: BrightScriptLaunchConfiguration) {
651
        if (config.deepLinkUrl) {
12!
UNCOV
652
            config.deepLinkUrl = config.deepLinkUrl.replace('${host}', config.host);
×
UNCOV
653
            config.deepLinkUrl = config.deepLinkUrl.replace('${promptForHost}', config.host);
×
UNCOV
654
            if (config.deepLinkUrl.includes('${promptForQueryParams}')) {
×
655
                let queryParams = await this.openInputBox('Querystring params for deep link');
×
656
                config.deepLinkUrl = config.deepLinkUrl.replace('${promptForQueryParams}', queryParams);
×
657
            }
658
            if (config.deepLinkUrl === '${promptForDeepLinkUrl}') {
×
659
                config.deepLinkUrl = await this.openInputBox('Full deep link url');
×
660
            }
661
        }
662
        return config;
12✔
663
    }
664

665
    /**
666
     * Helper to open a vscode input box ui
667
     * @param placeHolder placeHolder text
668
     * @param value default value
669
     */
670
    private async openInputBox(placeHolder: string, value = '') {
×
UNCOV
671
        return vscode.window.showInputBox({
×
672
            placeHolder: placeHolder,
673
            value: value
674
        });
675
    }
676

677
    /**
678
     * Get the bsconfig file, if available
679
     */
680
    public getBsConfig(workspaceFolder: vscode.Uri) {
681
        //try to load bsconfig settings
682
        let settings = util.getConfiguration('brightscript', workspaceFolder);
6✔
683
        let configFilePath = settings.get<string>('configFile');
6✔
684
        let isDefaultPath = false;
6✔
685
        if (!configFilePath) {
6!
686
            isDefaultPath = true;
6✔
687
            configFilePath = 'bsconfig.json';
6✔
688
        }
689

690
        //if the path is relative, resolve it relative to the workspace folder. If it's absolute, use as is (path.resolve handles this logic for us)
691
        let workspaceFolderPath = bslangUtil.uriToPath(workspaceFolder.toString());
6✔
692
        configFilePath = path.resolve(workspaceFolderPath, configFilePath);
6✔
693
        try {
6✔
694
            let bsconfig = bslangUtil.loadConfigFile(configFilePath);
6✔
UNCOV
695
            return bsconfig;
×
696
        } catch (e) {
697
            //only log the error if the user explicitly defined a config path
698
            if (!isDefaultPath) {
6!
UNCOV
699
                console.error(`Could not load bsconfig file at "${configFilePath}`);
×
700
            }
701
            return undefined;
6✔
702
        }
703
    }
704
}
705

706
export interface BrightScriptLaunchConfiguration extends LaunchConfiguration {
707
    /**
708
     * The name of this launch configuration
709
     */
710
    name: string;
711
    /**
712
     * The type of this debug configuration
713
     */
714
    type: string;
715
    /**
716
     * Should the debugger launch or attach. roku-debug only supports launching
717
     */
718
    request: 'launch' | 'attach';
719

720
    /**
721
     * A path to a file where all brightscript console output will be written. If falsey, file logging will be disabled.
722
     */
723
    logfilePath?: string;
724

725
    /**
726
     * Enable DAP protocol logging. Can be set via the `brightscript.debug.debugAdapterProtocolLogging` workspace setting or in launch.json.
727
     * The resolved absolute log file path is written to `debugAdapterProtocolLogFilePath` by `processDapLogFilePath`
728
     * and passed to the debug adapter process as the `ROKU_DAP_LOG_FILE` env var by the descriptor factory.
729
     */
730
    debugAdapterProtocolLogging?: boolean;
731

732
    /**
733
     * Resolved absolute path for the DAP protocol log file, populated by `processDapLogFilePath`.
734
     * Consumed by the DebugAdapterDescriptorFactory to inject `ROKU_DAP_LOG_FILE` into the adapter process.
735
     */
736
    debugAdapterProtocolLogFilePath?: string;
737
    /**
738
     *  If true, then the zip archive is NOT deleted after a debug session has been closed.
739
     * @default true
740
     */
741
    retainDeploymentArchive?: boolean;
742

743
    /**
744
     * A path to an environment variables file which will be used to augment the launch config
745
     */
746
    envFile?: string;
747

748
    /**
749
     * If injectRdbOnDeviceComponent is true and this is true the screen saver will be be disabled while the deployed application is running.
750
     */
751
    disableScreenSaver?: boolean;
752

753
    /**
754
     * If set, the remote control will be enabled/disabled at the start/end of the debug session, respectively.
755
     * @default { activateOnSessionStart: false, deactivateOnSessionEnd: false }
756
     */
757
    remoteControlMode?: { activateOnSessionStart?: boolean; deactivateOnSessionEnd?: boolean };
758
}
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