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

rokucommunity / roku-debug / 26120672946

19 May 2026 07:37PM UTC coverage: 70.727% (+0.7%) from 70.049%
26120672946

Pull #351

github

web-flow
Merge eb0b2e542 into 5bbd82240
Pull Request #351: 0.23.8

3328 of 5046 branches covered (65.95%)

Branch coverage included in aggregate %.

5834 of 7908 relevant lines covered (73.77%)

35.01 hits per line

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

79.63
/src/managers/ProjectManager.ts
1
import * as fsExtra from 'fs-extra';
2
import * as path from 'path';
2✔
3
import { rokuDeploy, RokuDeploy, util as rokuDeployUtil } from 'roku-deploy';
2✔
4
import type { FileEntry } from 'roku-deploy';
2✔
5
import * as fastGlob from 'fast-glob';
2✔
6
import type { BreakpointManager } from './BreakpointManager';
2✔
7
import { fileUtils, standardizePath as s } from '../FileUtils';
2✔
8
import type { LocationManager, SourceLocation } from './LocationManager';
2✔
9
import { util } from '../util';
2✔
10
import { logger } from '../logging';
2✔
11
import { Cache } from 'brighterscript/dist/Cache';
2✔
12
import { BscProjectThreaded } from '../bsc/BscProjectThreaded';
2✔
13
import type { ScopeFunction } from '../bsc/BscProject';
14
import type { Position } from 'brighterscript';
2✔
15
import type { SourceMapPayload } from 'module';
2✔
16

17
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
18
const replaceInFile = require('replace-in-file');
19

20
export const componentLibraryPostfix = '__lib';
21

22
/**
228✔
23
 * Manages the collection of brightscript projects being used in a debug session.
228✔
24
 * Will contain the main project (in rootDir), as well as component libraries.
228✔
25
 */
228✔
26
export class ProjectManager {
228✔
27
    public constructor(
28
        options: {
29
            /**
112✔
30
             * A class that keeps track of all the breakpoints for a debug session.
31
             * It needs to be notified of any changes in breakpoints
32
             */
33
            breakpointManager: BreakpointManager;
29✔
34
            locationManager: LocationManager;
29✔
35
        }
87!
36
    ) {
37
        this.breakpointManager = options.breakpointManager;
38
        this.locationManager = options.locationManager;
39
    }
40

41
    private breakpointManager: BreakpointManager;
42

43
    private locationManager: LocationManager;
15✔
44

15✔
45
    public launchConfiguration: {
45!
46
        enableSourceMaps?: boolean;
47
        enableDebugProtocol?: boolean;
15✔
48
        packagePath: string;
49
    };
50

51
    public logger = logger.createLogger('[ProjectManager]');
52

53
    public mainProject: Project;
54
    public componentLibraryProjects = [] as ComponentLibraryProject[];
55

×
56
    public addComponentLibraryProject(project: ComponentLibraryProject) {
×
57
        this.componentLibraryProjects.push(project);
×
58
    }
×
59

60
    public getAllProjects() {
61
        return [
×
62
            ...(this.mainProject ? [this.mainProject] : []),
63
            ...(this.componentLibraryProjects ?? [])
×
64
        ];
65
    }
66

67
    /**
68
     * Get the list of staging folder paths from all projects
69
     */
70
    public getStagingDirs() {
71
        let projects = [
×
72
            ...(this.mainProject ? [this.mainProject] : []),
×
73
            ...(this.componentLibraryProjects ?? [])
×
74
        ];
×
75
        return projects.map(x => x.stagingDir);
×
76
    }
77

78
    /**
79
     * Get all of the functions avaiable for all scopes for this file.
×
80
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
81
     * @returns
82
     */
83
    public async getScopeFunctionsForFile(pkgPath: string): Promise<Array<ScopeFunction>> {
84
        let completions: ScopeFunction[] = [];
85
        try {
86
            const fileInfo = await this.getStagingFileInfo(pkgPath);
87
            completions = await fileInfo?.project.getScopeFunctionsForFile(fileInfo.relativePath);
88
        } catch (error) {
89
            this.logger.error(`error loading completions for file ${pkgPath}`, error);
90
        }
91
        return completions;
92
    }
×
93

94
    /**
×
95
     * Get the range of the scope for the given position in the file
96
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
97
     * @param position the position in the file to get the scope range for
98
     */
99
    public async getScopeRange(pkgPath: string, position: Position) {
100
        try {
101
            const fileInfo = await this.getStagingFileInfo(pkgPath);
102
            const parentFunctionRange = await fileInfo?.project.getScopeRange(fileInfo.relativePath, position);
26✔
103
            if (parentFunctionRange) {
104
                const [startPosition, endPosition] = await Promise.all([
26✔
105
                    this.getSourceLocation(pkgPath, parentFunctionRange.start.line + 1),
26✔
106
                    this.getSourceLocation(pkgPath, parentFunctionRange.end.line + 1)
26✔
107
                ]);
26✔
108
                return {
210✔
109
                    start: {
210✔
110
                        line: startPosition.lineNumber,
111
                        column: startPosition.columnIndex
112
                    },
113
                    end: {
114
                        line: endPosition.lineNumber,
115
                        column: endPosition.columnIndex
116
                    }
117
                };
673✔
118
            }
210✔
119
        } catch (error) {
79✔
120
            this.logger.error(`error loading scope range for file ${pkgPath}`, error);
79✔
121
        }
122
        return undefined;
123
    }
26✔
124

125
    /**
126
     * Given a debugger path and line number, compensate for the injected breakpoint line offsets
127
     * @param filePath - the path to the file that may or may not have breakpoints
128
     * @param debuggerLineNumber - the line number from the debugger
129
     */
130
    public getLineNumberOffsetByBreakpoints(filePath: string, debuggerLineNumber: number) {
11✔
131
        let breakpoints = this.breakpointManager.getPermanentBreakpointsForFile(filePath);
11✔
132
        //throw out duplicate breakpoints (account for entry breakpoint) and sort them ascending
133
        breakpoints = this.breakpointManager.sortAndRemoveDuplicateBreakpoints(breakpoints);
134

11✔
135
        let sourceLineByDebuggerLine = {};
11!
136
        let sourceLineNumber = 0;
×
137
        for (let loopDebuggerLineNumber = 1; loopDebuggerLineNumber <= debuggerLineNumber; loopDebuggerLineNumber++) {
138
            sourceLineNumber++;
11✔
139
            sourceLineByDebuggerLine[loopDebuggerLineNumber] = sourceLineNumber;
140

11✔
141
            /**
1✔
142
             * A line with a breakpoint on it should share the same debugger line number.
1✔
143
             * The injected `STOP` line will be given the correct line number automatically,
144
             * but we need to compensate for the actual code line. So if there's a breakpoint
11✔
145
             * on this line, handle the next line's mapping as well (and skip one iteration of the loop)
146
             */
147
            // eslint-disable-next-line @typescript-eslint/no-loop-func
148
            let breakpointForLine = breakpoints.find(x => x.line === sourceLineNumber);
149
            if (breakpointForLine) {
150
                sourceLineByDebuggerLine[loopDebuggerLineNumber + 1] = sourceLineNumber;
151
                loopDebuggerLineNumber++;
152
            }
66!
153
        }
154

155
        return sourceLineByDebuggerLine[debuggerLineNumber];
11!
156
    }
×
157

158
    public sourceLocationCache = new Cache<string, Promise<SourceLocation>>();
11✔
159

160
    /**
5✔
161
     * @param debuggerPath
162
     * @param debuggerLineNumber - the 1-based line number from the debugger
25!
163
     * @param debuggerColumnNumber - the 1-based column number from the debugger
164
     */
165
    public async getSourceLocation(debuggerPath: string, debuggerLineNumber: number, debuggerColumnNumber = 1) {
166
        return this.sourceLocationCache.getOrAdd(`${debuggerPath}-${debuggerLineNumber}`, async () => {
167
            //get source location using
6✔
168
            let stagingFileInfo = await this.getStagingFileInfo(debuggerPath);
169
            if (!stagingFileInfo) {
170
                return;
171
            }
172
            let project = stagingFileInfo.project;
173

174
            //remove the component library postfix if present
175
            if (project instanceof ComponentLibraryProject) {
176
                stagingFileInfo.absolutePath = fileUtils.unPostfixFilePath(stagingFileInfo.absolutePath, project.postfix);
177
                stagingFileInfo.relativePath = fileUtils.unPostfixFilePath(stagingFileInfo.relativePath, project.postfix);
×
178
            }
179

×
180
            let sourceLocation = await this.locationManager.getSourceLocation({
×
181
                lineNumber: debuggerLineNumber,
182
                columnIndex: debuggerColumnNumber - 1,
×
183
                fileMappings: project.fileMappings,
184
                rootDir: project.rootDir,
185
                stagingFilePath: stagingFileInfo.absolutePath,
186
                stagingDir: project.stagingDir,
187
                sourceDirs: project.sourceDirs,
188
                enableSourceMaps: this.launchConfiguration?.enableSourceMaps ?? true
189
            });
190

191
            //if sourcemaps are disabled, and this is a telnet debug dession, account for breakpoint offsets
192
            if (sourceLocation && this.launchConfiguration?.enableSourceMaps === false && !this.launchConfiguration.enableDebugProtocol) {
193
                sourceLocation.lineNumber = this.getLineNumberOffsetByBreakpoints(sourceLocation.filePath, sourceLocation.lineNumber);
194
            }
195

196
            if (!sourceLocation?.filePath) {
15✔
197
                //couldn't find a source location. At least send back the staging file information so the user can still debug
198
                return {
15✔
199
                    filePath: stagingFileInfo.absolutePath,
3✔
200
                    lineNumber: sourceLocation?.lineNumber || debuggerLineNumber,
3!
201
                    columnIndex: debuggerColumnNumber - 1
3✔
202
                } as SourceLocation;
203
            } else {
204
                return sourceLocation;
×
205
            }
206
        });
207
    }
208

209
    /**
12✔
210
     *
211
     * @param stagingDir - the path to
212
     */
213
    public async registerEntryBreakpoint(stagingDir: string) {
15✔
214
        //find the main function from the staging flder
10✔
215
        let entryPoint = await fileUtils.findEntryPoint(stagingDir);
216

217
        //convert entry point staging location to source location
5✔
218
        let sourceLocation = await this.getSourceLocation(entryPoint.relativePath, entryPoint.lineNumber);
219

15!
220
        this.logger.info(`Registering entry breakpoint at ${sourceLocation.filePath}:${sourceLocation.lineNumber} (${entryPoint.pathAbsolute}:${entryPoint.lineNumber})`);
15✔
221
        //register the entry breakpoint
15✔
222
        this.breakpointManager.setBreakpoint(sourceLocation.filePath, {
223
            //+1 to select the first line of the function
224
            line: sourceLocation.lineNumber + 1
225
        });
226
    }
227

228
    /**
×
229
     * Given a debugger-relative file path, find the path to that file in the staging directory.
230
     * This supports the standard out dir, as well as component library out dirs
231
     * @param debuggerPath the path to the file which was provided by the debugger
232
     * @param stagingDir - the path to the root of the staging folder (where all of the files were copied before deployment)
15✔
233
     * @return a full path to the file in the staging directory
234
     */
235
    public async getStagingFileInfo(debuggerPath: string) {
2✔
236
        let project: Project;
237

238
        let componentLibraryIndex = fileUtils.getComponentLibraryIndexFromFileName(debuggerPath, componentLibraryPostfix);
239
        //component libraries
240
        if (componentLibraryIndex !== undefined) {
241
            let lib = this.componentLibraryProjects.find(x => x.libraryIndex === componentLibraryIndex);
242
            if (lib) {
337✔
243
                project = lib;
337✔
244
            } else {
245
                throw new Error(`There is no component library with index ${componentLibraryIndex}`);
246
            }
247
            //standard project files
248
        } else {
249
            project = this.mainProject;
337✔
250
        }
337!
251

×
252
        let relativePath: string;
253

337✔
254
        //if the path starts with a scheme (i.e. pkg:/ or complib:/, we have an exact match.
337!
255
        if (util.getFileScheme(debuggerPath)) {
×
256
            relativePath = util.removeFileScheme(debuggerPath);
257
        } else {
337✔
258
            relativePath = await fileUtils.findPartialFileInDirectory(debuggerPath, project.stagingDir);
337✔
259
        }
337✔
260
        if (relativePath) {
337✔
261
            relativePath = fileUtils.removeLeadingSlash(
262
                fileUtils.standardizePath(relativePath
92✔
263
                )
337✔
264
            );
337✔
265
            return {
337✔
266
                relativePath: relativePath,
337✔
267
                absolutePath: s`${project.stagingDir}/${relativePath}`,
337✔
268
                project: project
337✔
269
            };
337✔
270
        } else {
271
            return undefined;
272
        }
273
    }
48✔
274

275
    public dispose() {
276
        util.applyDispose(this.getAllProjects());
18✔
277
    }
10✔
278
}
279

280
export interface AddProjectParams {
18✔
281
    rootDir: string;
282
    outDir: string;
283
    packagePath?: string;
284
    sourceDirs?: string[];
285
    files: Array<FileEntry>;
286
    injectRaleTrackerTask?: boolean;
287
    raleTrackerTaskFileLocation?: string;
288
    injectRdbOnDeviceComponent?: boolean;
18✔
289
    rdbFilesBasePath?: string;
18✔
290
    bsConst?: Record<string, boolean>;
291
    stagingDir?: string;
1✔
292
    enhanceREPLCompletions: boolean;
293
}
294

295
export class Project {
296
    constructor(params: AddProjectParams) {
297
        if (!params?.rootDir) {
298
            throw new Error('rootDir is required');
299
        }
300
        this.rootDir = fileUtils.standardizePath(params.rootDir);
301

302
        if (!params?.outDir) {
303
            throw new Error('outDir is required');
×
304
        }
305
        this.outDir = fileUtils.standardizePath(params.outDir);
306
        this.stagingDir = params.stagingDir ?? rokuDeploy.getOptions(this).stagingDir;
307
        this.bsConst = params.bsConst;
18✔
308
        this.sourceDirs = (params.sourceDirs ?? [])
18✔
309
            //standardize every sourcedir
18✔
310
            .map(x => fileUtils.standardizePath(x));
18✔
311
        this.injectRaleTrackerTask = params.injectRaleTrackerTask ?? false;
312
        this.raleTrackerTaskFileLocation = params.raleTrackerTaskFileLocation;
313
        this.injectRdbOnDeviceComponent = params.injectRdbOnDeviceComponent ?? false;
314
        this.rdbFilesBasePath = params.rdbFilesBasePath;
315
        this.files = params.files ?? [];
316
        this.packagePath = params.packagePath;
317
        this.enhanceREPLCompletions = params.enhanceREPLCompletions;
318
    }
319
    public rootDir: string;
×
320
    public outDir: string;
×
321
    public packagePath: string;
322
    public sourceDirs: string[];
323
    public files: Array<FileEntry>;
×
324
    public stagingDir: string;
325
    public fileMappings: Array<{ src: string; dest: string }>;
326
    public bsConst: Record<string, boolean>;
327
    public injectRaleTrackerTask: boolean;
328
    public raleTrackerTaskFileLocation: string;
329
    public injectRdbOnDeviceComponent: boolean;
330
    public rdbFilesBasePath: string;
331
    public enhanceREPLCompletions: boolean;
332

333
    /**
×
334
     * A BrighterScript project for the stagingDir
×
335
     */
336
    private stagingBscProject = new BscProjectThreaded();
337

×
338
    //the default project doesn't have a postfix, but component libraries will have a postfix, so just use empty string to standardize the postfix logic
339
    public get postfix() {
340
        return '';
341
    }
342

343
    private logger = logger.createLogger(`[${ProjectManager.name}]`);
344

18✔
345
    public async stage() {
346
        if (!this.fileMappings) {
38✔
347
            this.fileMappings = await this.getFileMappings();
38✔
348
        }
38!
349

×
350
        //copy all project files to the staging folder
351
        await rokuDeploy.prepublishToStaging({
352
            rootDir: this.rootDir,
353
            stagingDir: this.stagingDir,
354
            files: this.fileMappings,
355
            outDir: this.outDir,
356
            //we already fetched the file mappings ourselves, so roku-deploy doesn't need to glob the files again
357
            resolveFilesArray: false
358
        });
359

360
        await this.preprocessStagingFiles();
361

135✔
362
        if (this.enhanceREPLCompletions) {
135✔
363
            //activate our background brighterscript ProgramBuilder now that the staging directory contains the final production project
135✔
364
            this.stagingBscProject.activate({
182✔
365
                rootDir: this.stagingDir,
182✔
366
                files: ['**/*'],
367
                watch: false,
368
                createPackage: false,
135✔
369
                deploy: false,
2,598✔
370
                copyToStaging: false,
135✔
371
                showDiagnosticsInConsole: false,
2,598✔
372
                logLevel: 'error',
373
                //this project is only used for file and scope lookups, so skip all validations since that takes a while and we don't care
2,598✔
374
                validate: false
2,416✔
375
            }).catch((e) => {
376
                this.logger.error('Error activating staging project.', e);
182✔
377
            });
182✔
378
        }
45✔
379

380
        //preload the original location of every file
381
        await this.resolveFileMappingsForSourceDirs();
382

383
        await this.transformManifestWithBsConst();
384

137✔
385
        await this.copyAndTransformRaleTrackerTask();
386

387
        await this.copyAndTransformRDB();
388
    }
389

390
    /**
105✔
391
     * Get all of the functions available for all scopes for this file.
105✔
392
     * @param relativePath path to the file relative to rootDir
393
     * @returns
105✔
394
     */
105✔
395
    public getScopeFunctionsForFile(relativePath: string) {
396
        if (this.enhanceREPLCompletions && this.stagingBscProject?.isActivated) {
105✔
397
            return this.stagingBscProject.getScopeFunctionsForFile({ relativePath: relativePath });
105✔
398
        } else {
103✔
399
            return [];
400
        }
401
    }
105✔
402

403
    /**
404
     * Get the range of the scope for the given position in the file
405
     * @param relativePath path to the file relative to rootDir
406
     * @param position the position in the file to get the scope range for
407
     */
408
    public async getScopeRange(relativePath: string, position: Position) {
64✔
409
        if (this.stagingBscProject?.isActivated) {
410
            return this.stagingBscProject.getScopeRange({ relativePath: relativePath, position: position });
411
        } else {
412
            return undefined;
413
        }
414
    }
415

41✔
416
    /**
417
     * If the project uses sourceDirs, replace every `fileMapping.src` with its original location in sourceDirs
418
     */
419
    private resolveFileMappingsForSourceDirs() {
420
        return Promise.all([
421
            this.fileMappings.map(async x => {
422
                let stagingFilePathRelative = fileUtils.getRelativePath(this.stagingDir, x.dest);
423
                let sourceDirFilePath = await fileUtils.findFirstRelativeFile(stagingFilePathRelative, this.sourceDirs);
86✔
424
                if (sourceDirFilePath) {
86✔
425
                    x.src = sourceDirFilePath;
86✔
426
                }
85✔
427
            })
56✔
428
        ]);
429
    }
430

29✔
431
    /**
432
     * Walk every staged file once and apply all necessary rewrites for files that were moved
87✔
433
     * from a different source location:
29✔
434
     *  - .map files: rewrite `sources` paths to be relative to the new staging location
29✔
435
     *  - .brs/.xml files: rewrite the sourceMappingURL comment path to point to the staged map
30✔
436
     */
30✔
437
    private async preprocessStagingFiles() {
438
        const srcToDestMap = new Map<string, string>();
439
        const destToSrcMap = new Map<string, string>();
29✔
440
        for (const mapping of this.fileMappings) {
29✔
441
            srcToDestMap.set(mapping.src, mapping.dest);
442
            destToSrcMap.set(mapping.dest, mapping.src);
443
        }
1✔
444

445
        //walk over every file
446
        const stagedFiles: string[] = (await fastGlob('**/*', { cwd: this.stagingDir, absolute: true, onlyFiles: true }))
447
            .map((f: string) => fileUtils.standardizePath(f));
448

449
        await Promise.all(stagedFiles.map(async (stagingFilePath: string) => {
450
            const originalSrcPath = destToSrcMap.get(stagingFilePath);
451

452
            // Skip files not in fileMappings (e.g. generated after staging)
453
            if (!originalSrcPath) {
454
                return;
455
            }
113✔
456

457
            const ext = path.extname(stagingFilePath).toLowerCase();
458

113✔
459
            if (ext === '.map') {
59✔
460
                await this.fixSourceMapSources({
461
                    stagingMapPath: stagingFilePath,
462
                    originalMapPath: originalSrcPath
463
                });
177!
464
            } else {
465
                await this.fixSourceMapComment(stagingFilePath, originalSrcPath, srcToDestMap);
466
            }
467
        }));
177!
468
    }
469

470
    /**
471
     * Per-path write locks. Async file ops interleave (e.g. `preprocessStagingFiles` fans out
177!
472
     * tasks that can target the same staging `.map`), so we queue all writes to a given path
473
     * through a single promise chain. Entries clean themselves up once their chain is idle.
474
     */
475
    private writeLocks = new Map<string, Promise<unknown>>();
177!
476

477
    private serializeWrite<T>(filePath: string, work: () => Promise<T>): Promise<T> {
478
        const key = fileUtils.standardizePath(filePath).toLowerCase();
479
        const prev = this.writeLocks.get(key) ?? Promise.resolve();
54✔
480
        //run work whether the prior op resolved or rejected — failures upstream shouldn't poison the chain
481
        const next = prev.then(work, work);
482
        this.writeLocks.set(key, next);
483
        //drop the entry once nothing else has chained onto it
484
        void next.catch(() => { /* swallow — caller's await sees the real error */ }).then(() => {
485
            if (this.writeLocks.get(key) === next) {
486
                this.writeLocks.delete(key);
487
            }
488
        });
489
        return next;
490
    }
491

492
    /**
493
     * Serialized wrapper around `fsExtra.writeFile`. Use in place of `fsExtra.writeFile` anywhere
494
     * a path might also be written by another concurrent task in this Project.
495
     */
496
    private writeFile(filePath: string, data: Parameters<typeof fsExtra.writeFile>[1], options?: Parameters<typeof fsExtra.writeFile>[2]) {
497
        return this.serializeWrite(filePath, () => fsExtra.writeFile(filePath, data, options));
498
    }
137✔
499

500
    /**
137✔
501
     * Serialized wrapper around `fsExtra.copyFile`. The dest path is the one we serialize on,
70✔
502
     * since that's what gets written.
503
     */
67✔
504
    private copyFile(srcPath: string, destPath: string) {
67✔
505
        return this.serializeWrite(destPath, () => fsExtra.copyFile(srcPath, destPath));
506
    }
67✔
507

34✔
508
    /**
509
     * Rewrite the `sources` paths in a staged .map file so they are relative to the map's
510
     * new staging location rather than the original source directory.
511
     */
34✔
512
    private async fixSourceMapSources(params: { stagingMapPath: string; originalMapPath: string }) {
513
        const { stagingMapPath, originalMapPath } = params;
514

515
        try {
516
            const sourceMap = await fsExtra.readJsonSync(stagingMapPath) as SourceMapPayload;
517
            if (!Array.isArray(sourceMap.sources) || sourceMap.sources.length === 0) {
518
                return;
33✔
519
            }
520
            // Resolve sources relative to original map's base dir (honoring sourceRoot if present)
33✔
521
            const originalBaseDir = path.resolve(
26✔
522
                //sourceRoot should resolve relative to originalMapDir, or keep as-is when absolute path
523
                path.dirname(originalMapPath),
524
                sourceMap.sourceRoot ?? ''
7✔
525
            );
526

527
            const stagingMapDir = path.dirname(stagingMapPath);
528

7✔
529
            sourceMap.sources = sourceMap.sources.map((source) => {
530
                const absoluteSourcePath = path.resolve(originalBaseDir, source);
531
                return fileUtils.standardizePath(path.relative(stagingMapDir, absoluteSourcePath));
34!
532
            });
34✔
533

34✔
534
            // Clear sourceRoot since sources are now relative to the map file's new location
34✔
535
            delete sourceMap.sourceRoot;
34✔
536

537
            await this.writeFile(stagingMapPath, JSON.stringify(sourceMap));
538
        } catch (e) {
×
539
            this.logger.error(`Error updating source map sources for '${stagingMapPath}'`, e);
540
        }
541
    }
542

543

41✔
544
    public static readonly binaryExtensions = new Set([
41!
545
        // images
41✔
546
        '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg',
547
        '.heic', '.heif', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng',
41✔
548
        // video
549
        '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg',
550
        '.m2v', '.ts', '.mts', '.m2ts', '.vob', '.ogv', '.3gp', '.3g2',
551
        // audio
41✔
552
        '.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
553
        // fonts
554
        '.ttf', '.otf', '.woff', '.woff2', '.eot',
555
        // archives / binary containers
556
        '.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar', '.pkg', '.exe', '.dll', '.so',
557
        // documents / other binary formats
18✔
558
        '.pdf', '.psd', '.ai', '.eps', '.indd',
1✔
559
        // roku-specific
1!
560
        '.roku', '.rdb', '.squashfs'
561
    ]);
1✔
562

1✔
563
    /**
1✔
564
     * Extracts the sourceMappingURL comment from the given file contents.
565
     *
566
     * `match[3]` is the path (which may be relative or absolute)
567
     * @param contents
568
     * @returns
569
     */
5✔
570
    public static getSourceMapComment(contents: string) {
5✔
571

572
        //https://regex101.com/r/FMRJNy/2
573
        const commentMatch = [
5✔
574
            ...contents.matchAll(/^([ \t]*(?:'|<!--)?[ \t]*)((?:\/\/)?[ \t]*[#@][ \t]*sourceMappingURL=(.+\b))(?:|-->)?/gm)
53✔
575
        ].pop();
5✔
576
        if (commentMatch) {
5✔
577
            return {
5✔
578
                /**
579
                 * The entire matched comment, including any leading whitespace and comment characters (e.g. `'` or `<!--`), which should be preserved when rewriting the comment
580
                 */
5!
581
                fullMatch: commentMatch?.[0],
582
                /**
5✔
583
                 * The leading whitespace and comment characters (e.g. `'` or `<!--`) before the actual `sourceMappingURL` text, which should be preserved when rewriting the comment
7✔
584
                 */
7!
585
                leadingInfo: commentMatch?.[1],
7✔
586
                /**
587
                 * The entire comment text without the leadingInfo (e.g. `//# sourceMappingURL=someFile.map`)
588
                 */
×
589
                wholeComment: commentMatch?.[2],
590
                /**
7✔
591
                 * The path to the source map file (e.g. `someFile.map`)
592
                 */
593
                mapPath: commentMatch?.[3]
5!
594
            };
×
595
        } else {
596
            return undefined;
597
        }
598
    }
5✔
599

600
    /**
601
     * Rewrite the sourceMappingURL comment in a staged .brs or .xml file so the path points
602
     * to the map file's new staging location.
×
603
     *
604
     * Recognised comment forms (# and legacy @ are both accepted; // is optional for brs/xml):
605
     *   BRS:   ' [//] [#|@] sourceMappingURL=<path>
606
     *   XML:   <!-- [//] [#|@] sourceMappingURL=<path> -->
607
     *   other: // \s* [#|@] sourceMappingURL=<path>
608
     *
609
     * When rewriting, the canonical modern form is always written:
610
     *   BRS:   '//# sourceMappingURL=<path>
31✔
611
     *   XML:   <!--//# sourceMappingURL=<path> -->
18✔
612
     *   other: //# sourceMappingURL=<path>
613
     */
13✔
614
    private async fixSourceMapComment(stagingFilePath: string, originalSrcPath: string, srcToDestMap: Map<string, string>) {
13✔
615
        try {
13✔
616
            //if this is a media file, skip it because it won't have a source map
617
            if (Project.binaryExtensions.has(path.extname(stagingFilePath).toLowerCase())) {
13✔
618
                return;
619
            }
620
            let contents = await fsExtra.readFile(stagingFilePath, 'utf8');
621

622
            const commentMatch = Project.getSourceMapComment(contents);
12✔
623

12✔
624
            let absoluteMapPath: string;
625

626
            if (commentMatch) {
6✔
627
                absoluteMapPath = fileUtils.standardizePath(
628
                    path.isAbsolute(commentMatch.mapPath)
12✔
629
                        ? commentMatch.mapPath
630
                        : path.resolve(path.dirname(originalSrcPath), commentMatch.mapPath)
631
                );
13✔
632

26✔
633
                //copy the sourcemap right next to our file in staging
12✔
634
                absoluteMapPath = await this.colocateSourceMap({
13✔
635
                    absoluteMapPath: absoluteMapPath,
1✔
636
                    stagingFilePath: stagingFilePath
637
                });
638

639
            } else {
×
640
                // No comment — check if a colocated map exists next to the original source file
641
                absoluteMapPath = fileUtils.standardizePath(originalSrcPath + '.map');
642

643
                //there is no colocated map next to the original source file
644
                if (!await fsExtra.pathExists(absoluteMapPath)) {
645
                    return;
646
                }
647

34✔
648
                //copy the sourcemap right next to our file in staging — the debugger will find it automatically
19✔
649
                await this.colocateSourceMap({
650
                    absoluteMapPath: absoluteMapPath,
15✔
651
                    stagingFilePath: stagingFilePath
15✔
652
                });
653
                return;
654
            }
655

656
            // If the map was also staged, point at its new location; otherwise point back at the original
657
            const mapTarget = srcToDestMap.get(absoluteMapPath) ?? absoluteMapPath;
658
            const newRelativePath = fileUtils.standardizePath(
15✔
659
                path.relative(path.dirname(stagingFilePath), mapTarget)
28✔
660
            );
661

28!
662
            const newComment = `${commentMatch.leadingInfo.trimEnd()}//# sourceMappingURL=${newRelativePath}`;
28✔
663
            contents = contents.replace(commentMatch.fullMatch, newComment);
28✔
664
            await this.writeFile(stagingFilePath, contents, 'utf8');
28✔
665
        } catch (e) {
666
            this.logger.error(`Error updating sourceMappingURL comment in '${stagingFilePath}'`, e);
28✔
667
        }
28✔
668
    }
669

670
    private async colocateSourceMap(options: { stagingFilePath: string; absoluteMapPath: string }) {
15✔
671
        //copy the sourcemap right next to our file (skip if it's already there)
672
        const stagingMapPath = `${options.stagingFilePath}.map`;
673
        if (fileUtils.standardizePath(options.absoluteMapPath) !== fileUtils.standardizePath(stagingMapPath)) {
674
            await this.copyFile(options.absoluteMapPath, stagingMapPath);
675
        }
12✔
676
        await this.fixSourceMapSources({
12✔
677
            stagingMapPath: stagingMapPath,
678
            originalMapPath: options.absoluteMapPath
679
        });
6✔
680
        return stagingMapPath;
681
    }
12✔
682

683

684
    /**
14✔
685
     * Apply the bsConst transformations to the manifest file for this project
42✔
686
     */
12✔
687
    public async transformManifestWithBsConst() {
14✔
688
        if (this.bsConst) {
2✔
689
            let manifestPath = s`${this.stagingDir}/manifest`;
690
            if (await fsExtra.pathExists(manifestPath)) {
691
                // Update the bs_const values in the manifest in the staging folder before side loading the channel
692
                let fileContents = (await fsExtra.readFile(manifestPath)).toString();
1✔
693
                fileContents = this.updateManifestBsConsts(this.bsConst, fileContents);
694
                await this.writeFile(manifestPath, fileContents);
695
            }
696
        }
697
    }
698

699
    public updateManifestBsConsts(consts: Record<string, boolean>, fileContents: string): string {
700
        let bsConstLine: string;
3✔
701
        let missingConsts: string[] = [];
702
        let lines = fileContents.split(/\r?\n/g);
703

704
        let newLine: string;
3✔
705
        //loop through the lines until we find the bs_const line if it exists
3✔
706
        for (const line of lines) {
707
            if (line.toLowerCase().startsWith('bs_const')) {
2✔
708
                bsConstLine = line;
2✔
709
                newLine = line;
710
                break;
711
            }
3!
712
        }
×
713

714
        if (bsConstLine) {
715
            // update the consts in the manifest and check for missing consts
3✔
716
            missingConsts = Object.keys(consts).reduce((results, key) => {
717
                let match = new RegExp('(' + key + '\\s*=\\s*[true|false]+[^\\S\\r\\n]*\)', 'i').exec(bsConstLine);
718
                if (match) {
719
                    newLine = newLine.replace(match[1], `${key}=${consts[key].toString()}`);
720
                } else {
721
                    results.push(key);
3!
722
                }
×
723

724
                return results;
725
            }, []);
726

727
            // check for consts that where not in the manifest
728
            if (missingConsts.length > 0) {
729
                throw new Error(`The following bs_const keys were not defined in the channel's manifest:\n\n${missingConsts.join(',\n')}`);
730
            } else {
19✔
731
                // update the manifest contents
19✔
732
                return fileContents.replace(bsConstLine, newLine);
733
            }
734
        } else {
735
            throw new Error('bs_const was defined in the launch.json but not in the channel\'s manifest');
×
736
        }
737
    }
738

2✔
739
    public static RALE_TRACKER_TASK_CODE = `if true = CreateObject("roAppInfo").IsDev() then m.vscode_rale_tracker_task = createObject("roSGNode", "TrackerTask") ' Roku Advanced Layout Editor Support`;
2✔
740
    public static RALE_TRACKER_ENTRY = 'vscode_rale_tracker_entry';
741
    /**
742
     * Search the project files for the comment "' vscode_rale_tracker_entry" and replace it with the code needed to start the TrackerTask.
743
     */
744
    public async copyAndTransformRaleTrackerTask() {
745
        // inject the tracker task into the staging files if we have everything we need
746
        if (!this.injectRaleTrackerTask || !this.raleTrackerTaskFileLocation) {
747
            return;
748
        }
749
        try {
750
            await fsExtra.copy(this.raleTrackerTaskFileLocation, s`${this.stagingDir}/components/TrackerTask.xml`);
751
            this.logger.log('Tracker task successfully injected');
752
            // Search for the tracker task entry injection point
753
            const trackerReplacementResult = await replaceInFile({
754
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
755
                from: new RegExp(`^.*'\\s*${Project.RALE_TRACKER_ENTRY}.*$`, 'mig'),
756
                to: (match: string) => {
757
                    // Strip off the comment
2✔
758
                    let startOfLine = match.substring(0, match.indexOf(`'`));
2✔
759
                    if (/[\S]/.exec(startOfLine)) {
2✔
760
                        // There was some form of code before the tracker entry
2✔
761
                        // append and use single line syntax
762
                        startOfLine += ': ';
763
                    }
764
                    return `${startOfLine}${Project.RALE_TRACKER_TASK_CODE}`;
144✔
765
                }
144✔
766
            });
144✔
767
            const injectedFiles = trackerReplacementResult
144✔
768
                .filter(result => result.hasChanged)
769
                .map(result => result.file);
770

771
            if (injectedFiles.length === 0) {
772
                console.error(`WARNING: Unable to find an entry point for Tracker Task.\nPlease make sure that you have the following comment in your BrightScript project: "\' ${Project.RALE_TRACKER_ENTRY}"`);
773
            }
774
        } catch (err) {
10✔
775
            console.error(err);
776
        }
10✔
777
    }
10✔
778

1✔
779
    public static RDB_ODC_NODE_CODE = `if true = CreateObject("roAppInfo").IsDev() then m.vscode_rdb_odc_node = createObject("roSGNode", "RTA_OnDeviceComponent") ' RDB OnDeviceComponent`;
780
    public static RDB_ODC_ENTRY = 'vscode_rdb_on_device_component_entry';
781
    /**
9✔
782
     * Search the project files for the RTA_ODC_ENTRY comment and replace it with the code needed to start RTA_OnDeviceComponent which is used by RDB.
783
     */
9✔
784
    public async copyAndTransformRDB() {
785
        // inject the on device component into the staging files if we have everything we need
5✔
786
        if (!this.injectRdbOnDeviceComponent || !this.rdbFilesBasePath) {
5✔
787
            return;
5!
788
        }
5✔
789
        try {
790

791
            let files: string[] = await fastGlob(
×
792
                //fast-glob requires forward slashes, so convert any backslashes in the provided path to forward slashes before globbing
793
                `${this.rdbFilesBasePath}/**/*`.replace(/[\\/]+/g, '/'),
794
                {
795
                    cwd: './',
796
                    absolute: false,
797
                    followSymbolicLinks: true
798
                }
799
            );
800
            for (let filePathAbsolute of files) {
9✔
801
                const promises = [];
9✔
802
                //only include files (i.e. skip directories)
803
                if (await util.isFile(filePathAbsolute)) {
9✔
804
                    const relativePath = s`${filePathAbsolute}`.replace(s`${this.rdbFilesBasePath}`, '');
9!
805
                    const destinationPath = s`${this.stagingDir}/${relativePath}`;
806
                    promises.push(fsExtra.copy(filePathAbsolute, destinationPath));
9✔
807
                }
808
                await Promise.all(promises);
809
                this.logger.log('RDB OnDeviceComponent successfully injected');
×
810
            }
811

9✔
812
            // Search for the tracker task entry injection point
9✔
813
            const replacementResult = await replaceInFile({
814
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
9✔
815
                from: new RegExp(`^.*'\\s*${Project.RDB_ODC_ENTRY}.*$`, 'mig'),
816
                to: (match: string) => {
817
                    // Strip off the comment
818
                    let startOfLine = match.substring(0, match.indexOf(`'`));
819
                    if (/[\S]/.exec(startOfLine)) {
820
                        // There was some form of code before the tracker entry
9✔
821
                        // append and use single line syntax
10✔
822
                        startOfLine += ': ';
823
                    }
9✔
824
                    return `${startOfLine}${Project.RDB_ODC_NODE_CODE}`;
825
                }
826
            });
827
            const injectedFiles = replacementResult
828
                .filter(result => result.hasChanged)
829
                .map(result => result.file);
830

12✔
831
            if (injectedFiles.length === 0) {
832
                console.error(`WARNING: Unable to find an entry point for RDB.\nPlease make sure that you have the following comment in your BrightScript project: "\' ${Project.RDB_ODC_ENTRY}"`);
833
            }
2✔
834
        } catch (err) {
2✔
835
            console.error(err);
×
836
        }
×
837
    }
×
838

839
    /**
×
840
     *
841
     * @param stagingPath
×
842
     */
843
    public async zipPackage(params: { retainStagingFolder: boolean }) {
844
        const options = rokuDeploy.getOptions({
845
            ...this,
2✔
846
            ...params
847
        });
848

849
        let packagePath = this.packagePath;
850
        if (!this.packagePath) {
851
            //make sure the output folder exists
852
            await fsExtra.ensureDir(options.outDir);
853

8✔
854
            packagePath = rokuDeploy.getOutputZipFilePath(options);
8✔
855
        }
8✔
856

6✔
857
        //ensure the manifest file exists in the staging folder
858
        if (!await rokuDeployUtil.fileExistsCaseInsensitive(`${options.stagingDir}/manifest`)) {
859
            throw new Error(`Cannot zip package: missing manifest file in "${options.stagingDir}"`);
2✔
860
        }
861

862
        // create a zip of the staging folder
863
        await rokuDeploy.zipFolder(options.stagingDir, packagePath, undefined, [
864
            '**/*',
865
            //exclude sourcemap files (they're large and can't be parsed on-device anyway...)
2✔
866
            '!**/*.map'
867
        ]);
868

869
        //delete the staging folder unless told to retain it.
870
        if (options.retainStagingDir !== true) {
871
            await fsExtra.remove(options.stagingDir);
872
        }
873
    }
874

875
    /**
876
     * Get the file paths from roku-deploy, and ensure the dest paths are absolute
877
     * (`dest` paths are relative in later versions of roku-deploy)
878
     */
879
    protected async getFileMappings() {
880
        let fileMappings = await rokuDeploy.getFilePaths(this.files, this.rootDir, true, this.stagingDir);
881
        return fileMappings;
882
    }
883

884
    public dispose() {
885
        this.stagingBscProject?.dispose?.();
886
    }
887
}
888

889
export interface ComponentLibraryConstructorParams extends AddProjectParams {
890
    outFile: string;
891
    libraryIndex: number;
892
    install?: boolean;
893
}
894

895
export class ComponentLibraryProject extends Project {
896
    constructor(params: ComponentLibraryConstructorParams) {
897
        super(params);
898
        this.outFile = params.outFile;
899
        this.libraryIndex = params.libraryIndex;
900
        this.install = params.install ?? false;
901
    }
902
    public outFile: string;
903
    public libraryIndex: number;
904
    public install: boolean;
905
    /**
906
     * The name of the component library that this project represents. This is loaded during `this.computeOutFileName`
907
     */
908
    public name: string;
909

910
    /**
911
     * Takes a component Library and checks the outFile for replaceable values pulled from the libraries manifest
912
     * @param manifestPath the path to the manifest file to check
913
     */
914
    private async computeOutFileName(manifestPath: string) {
915
        let regexp = /\$\{([\w\d_]*)\}/;
916
        let renamingMatch: RegExpExecArray;
917
        let manifestValues = await util.convertManifestToObject(manifestPath);
918
        if (!manifestValues) {
919
            throw new Error(`Cannot find manifest file at "${manifestPath}"\n\nCould not complete automatic component library naming.`);
920
        }
921

922
        //load the component libary name from the manifest
923
        this.name = manifestValues.sg_component_libs_provided || manifestValues.bs_libs_provided;
924

925
        // search the outFile for replaceable values such as ${title}
926
        while ((renamingMatch = regexp.exec(this.outFile))) {
927

928
            // replace the replaceable key with the manifest value
929
            let manifestVariableName = renamingMatch[1];
930
            let manifestVariableValue = manifestValues[manifestVariableName];
931
            if (manifestVariableValue) {
932
                this.outFile = this.outFile.replace(renamingMatch[0], manifestVariableValue);
933
            } else {
934
                throw new Error(`Cannot find manifest value:\n"${manifestVariableName}"\n\nCould not complete automatic component library naming.`);
935
            }
936
        }
937
    }
938

939
    public async stage() {
940
        /*
941
         Compute the file mappings now (i.e. don't let the parent class compute them).
942
         This must be done BEFORE finding the manifest file location.
943
         */
944
        this.fileMappings = await this.getFileMappings();
945

946
        let expectedManifestDestPath = fileUtils.standardizePath(`${this.stagingDir}/manifest`).toLowerCase();
947
        //find the file entry with the `dest` value of `${stagingDir}/manifest` (case insensitive)
948
        let manifestFileEntry = this.fileMappings.find(x => x.dest.toLowerCase() === expectedManifestDestPath);
949
        if (manifestFileEntry) {
950
            //read the manifest from `src` since nothing has been copied to staging yet
951
            await this.computeOutFileName(manifestFileEntry.src);
952
        } else {
953
            throw new Error(`Could not find manifest path for component library at '${this.rootDir}'`);
954
        }
955
        let fileNameWithoutExtension = path.basename(this.outFile, path.extname(this.outFile));
956

957
        let defaultStagingDir = this.stagingDir;
958

959
        //compute the staging folder path.
960
        this.stagingDir = s`${this.outDir}/${fileNameWithoutExtension}`;
961

962
        /*
963
          The fileMappings were created using the default stagingDir (because we need the manifest path
964
          to compute the out file name and staging path), so we need to replace the default stagingDir
965
          with the actual stagingDir.
966
         */
967
        for (let fileMapping of this.fileMappings) {
968
            fileMapping.dest = fileUtils.replaceCaseInsensitive(fileMapping.dest, defaultStagingDir, this.stagingDir);
969
        }
970

971
        return super.stage();
972
    }
973

974
    /**
975
     * The text used as a postfix for every brs file so we can accurately track the location of the files
976
     * back to their original component library whenever the debugger truncates the file path.
977
     */
978
    public get postfix() {
979
        return `${componentLibraryPostfix}${this.libraryIndex}`;
980
    }
981

982
    public async postfixFiles() {
983
        let pathDetails = {};
984
        await Promise.all(this.fileMappings.map(async (fileMapping) => {
985
            let relativePath = fileUtils.removeLeadingSlash(
986
                fileUtils.getRelativePath(this.stagingDir, fileMapping.dest)
987
            );
988
            let postfixedPath = fileUtils.postfixFilePath(relativePath, this.postfix, ['.brs']);
989
            if (postfixedPath !== relativePath) {
990
                // Rename the brs files to include the postfix namespacing tag
991
                await fsExtra.move(fileMapping.dest, path.join(this.stagingDir, postfixedPath));
992
                // Add to the map of original paths and the new paths
993
                pathDetails[postfixedPath] = relativePath;
994
            }
995
        }));
996

997
        // Update all the file name references in the library to the new file names
998
        await replaceInFile({
999
            files: [
1000
                path.join(this.stagingDir, '**/*.xml'),
1001
                path.join(this.stagingDir, '**/*.brs')
1002
            ],
1003
            from: /uri\s*=\s*"(.+)\.brs"/gi,
1004
            to: (match: string) => {
1005
                // only alter file ending if it is a) pkg:/ url or b) relative url
1006
                let isPkgUrl = !!/^uri\s*=\s*"(pkg:|libpkg:)\//i.exec(match);
1007
                let isRelativeUrl = !/:\//i.exec(match);
1008
                if (isPkgUrl || isRelativeUrl) {
1009
                    return match.replace('.brs', this.postfix + '.brs');
1010
                } else {
1011
                    return match;
1012
                }
1013
            }
1014
        });
1015
    }
1016
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc