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

rokucommunity / roku-debug / 28191818298

25 Jun 2026 06:29PM UTC coverage: 70.995% (+0.06%) from 70.938%
28191818298

Pull #378

github

web-flow
Merge 193653c0d into 456a8fdc5
Pull Request #378: Add per-component-library option to disable file postfixing

3530 of 5249 branches covered (67.25%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

5727 of 7790 relevant lines covered (73.52%)

45.61 hits per line

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

81.09
/src/managers/ProjectManager.ts
1
import * as fsExtra from 'fs-extra';
2✔
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';
5
import * as fastGlob from 'fast-glob';
2✔
6
import type { BreakpointManager } from './BreakpointManager';
7
import { fileUtils, standardizePath as s } from '../FileUtils';
2✔
8
import type { LocationManager, SourceLocation } from './LocationManager';
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';
15
import type { SourceMapPayload } from 'module';
16

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

20
export const componentLibraryPostfix = '__lib';
2✔
21

22
/**
23
 * Staging info for a single project, used when describing all projects to a client.
24
 */
25
export interface ProjectStagingInfo {
26
    /**
27
     * The kind of project. `main` is the application project (always exactly one); `componentLibrary`
28
     * is a component library project.
29
     */
30
    type: 'main' | 'componentLibrary';
31
    /**
32
     * Absolute path to the project's staging directory.
33
     */
34
    stagingDir: string;
35
}
36

37
/**
38
 * Manages the collection of brightscript projects being used in a debug session.
39
 * Will contain the main project (in rootDir), as well as component libraries.
40
 */
41
export class ProjectManager {
2✔
42
    public constructor(
43
        options: {
44
            /**
45
             * A class that keeps track of all the breakpoints for a debug session.
46
             * It needs to be notified of any changes in breakpoints
47
             */
48
            breakpointManager: BreakpointManager;
49
            locationManager: LocationManager;
50
        }
51
    ) {
52
        this.breakpointManager = options.breakpointManager;
338✔
53
        this.locationManager = options.locationManager;
338✔
54
    }
55

56
    private breakpointManager: BreakpointManager;
57

58
    private locationManager: LocationManager;
59

60
    public launchConfiguration: {
61
        enableSourceMaps?: boolean;
62
        enableDebugProtocol?: boolean;
63
        packagePath: string;
64
    };
65

66
    public logger = logger.createLogger('[ProjectManager]');
338✔
67

68
    public mainProject: Project;
69
    public componentLibraryProjects = [] as ComponentLibraryProject[];
338✔
70

71
    public addComponentLibraryProject(project: ComponentLibraryProject) {
72
        this.componentLibraryProjects.push(project);
254✔
73
    }
74

75
    public getAllProjects() {
76
        return [
35✔
77
            ...(this.mainProject ? [this.mainProject] : []),
35✔
78
            ...(this.componentLibraryProjects ?? [])
105!
79
        ];
80
    }
81

82
    /**
83
     * Get the list of staging folder paths from all projects
84
     */
85
    public getStagingDirs() {
86
        let projects = [
15✔
87
            ...(this.mainProject ? [this.mainProject] : []),
15✔
88
            ...(this.componentLibraryProjects ?? [])
45!
89
        ];
90
        return projects.map(x => x.stagingDir);
15✔
91
    }
92

93
    /**
94
     * Get staging-dir info for every project. The main project is always first; component libraries
95
     * follow in order. This main-first ordering is a contract that clients rely on, so it is covered
96
     * by unit tests to guard against regressions.
97
     */
98
    public getProjectStagingInfo(): ProjectStagingInfo[] {
99
        return this.getAllProjects().map((project) => ({
4✔
100
            type: project instanceof ComponentLibraryProject ? 'componentLibrary' : 'main',
4✔
101
            stagingDir: project.stagingDir
102
        }));
103
    }
104

105
    /**
106
     * Get all of the functions avaiable for all scopes for this file.
107
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
108
     * @returns
109
     */
110
    public async getScopeFunctionsForFile(pkgPath: string): Promise<Array<ScopeFunction>> {
111
        let completions: ScopeFunction[] = [];
×
112
        try {
×
113
            const fileInfo = await this.getStagingFileInfo(pkgPath);
×
114
            completions = await fileInfo?.project.getScopeFunctionsForFile(fileInfo.relativePath);
×
115
        } catch (error) {
116
            this.logger.error(`error loading completions for file ${pkgPath}`, error);
×
117
        }
118
        return completions;
×
119
    }
120

121
    /**
122
     * Get the range of the scope for the given position in the file
123
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
124
     * @param position the position in the file to get the scope range for
125
     */
126
    public async getScopeRange(pkgPath: string, position: Position) {
127
        try {
×
128
            const fileInfo = await this.getStagingFileInfo(pkgPath);
×
129
            const parentFunctionRange = await fileInfo?.project.getScopeRange(fileInfo.relativePath, position);
×
130
            if (parentFunctionRange) {
×
131
                const [startPosition, endPosition] = await Promise.all([
×
132
                    this.getSourceLocation(pkgPath, parentFunctionRange.start.line + 1),
133
                    this.getSourceLocation(pkgPath, parentFunctionRange.end.line + 1)
134
                ]);
135
                return {
×
136
                    start: {
137
                        line: startPosition.lineNumber,
138
                        column: startPosition.columnIndex
139
                    },
140
                    end: {
141
                        line: endPosition.lineNumber,
142
                        column: endPosition.columnIndex
143
                    }
144
                };
145
            }
146
        } catch (error) {
147
            this.logger.error(`error loading scope range for file ${pkgPath}`, error);
×
148
        }
149
        return undefined;
×
150
    }
151

152
    /**
153
     * Given a debugger path and line number, compensate for the injected breakpoint line offsets
154
     * @param filePath - the path to the file that may or may not have breakpoints
155
     * @param debuggerLineNumber - the line number from the debugger
156
     */
157
    public getLineNumberOffsetByBreakpoints(filePath: string, debuggerLineNumber: number) {
158
        let breakpoints = this.breakpointManager.getPermanentBreakpointsForFile(filePath);
26✔
159
        //throw out duplicate breakpoints (account for entry breakpoint) and sort them ascending
160
        breakpoints = this.breakpointManager.sortAndRemoveDuplicateBreakpoints(breakpoints);
26✔
161

162
        let sourceLineByDebuggerLine = {};
26✔
163
        let sourceLineNumber = 0;
26✔
164
        for (let loopDebuggerLineNumber = 1; loopDebuggerLineNumber <= debuggerLineNumber; loopDebuggerLineNumber++) {
26✔
165
            sourceLineNumber++;
210✔
166
            sourceLineByDebuggerLine[loopDebuggerLineNumber] = sourceLineNumber;
210✔
167

168
            /**
169
             * A line with a breakpoint on it should share the same debugger line number.
170
             * The injected `STOP` line will be given the correct line number automatically,
171
             * but we need to compensate for the actual code line. So if there's a breakpoint
172
             * on this line, handle the next line's mapping as well (and skip one iteration of the loop)
173
             */
174
            // eslint-disable-next-line @typescript-eslint/no-loop-func
175
            let breakpointForLine = breakpoints.find(x => x.line === sourceLineNumber);
673✔
176
            if (breakpointForLine) {
210✔
177
                sourceLineByDebuggerLine[loopDebuggerLineNumber + 1] = sourceLineNumber;
79✔
178
                loopDebuggerLineNumber++;
79✔
179
            }
180
        }
181

182
        return sourceLineByDebuggerLine[debuggerLineNumber];
26✔
183
    }
184

185
    public sourceLocationCache = new Cache<string, Promise<SourceLocation>>();
338✔
186

187
    /**
188
     * @param debuggerPath
189
     * @param debuggerLineNumber - the 1-based line number from the debugger
190
     * @param debuggerColumnNumber - the 1-based column number from the debugger
191
     */
192
    public async getSourceLocation(debuggerPath: string, debuggerLineNumber: number, debuggerColumnNumber = 1) {
11✔
193
        return this.sourceLocationCache.getOrAdd(`${debuggerPath}-${debuggerLineNumber}`, async () => {
11✔
194
            //get source location using
195
            let stagingFileInfo = await this.getStagingFileInfo(debuggerPath);
11✔
196
            if (!stagingFileInfo) {
11!
197
                return;
×
198
            }
199
            let project = stagingFileInfo.project;
11✔
200

201
            //remove the component library postfix if present
202
            if (project instanceof ComponentLibraryProject) {
11✔
203
                stagingFileInfo.absolutePath = fileUtils.unPostfixFilePath(stagingFileInfo.absolutePath, project.postfix);
1✔
204
                stagingFileInfo.relativePath = fileUtils.unPostfixFilePath(stagingFileInfo.relativePath, project.postfix);
1✔
205
            }
206

207
            let sourceLocation = await this.locationManager.getSourceLocation({
11✔
208
                lineNumber: debuggerLineNumber,
209
                columnIndex: debuggerColumnNumber - 1,
210
                fileMappings: project.fileMappings,
211
                rootDir: project.rootDir,
212
                stagingFilePath: stagingFileInfo.absolutePath,
213
                stagingDir: project.stagingDir,
214
                sourceDirs: project.sourceDirs,
215
                enableSourceMaps: this.launchConfiguration?.enableSourceMaps ?? true
66!
216
            });
217

218
            //if sourcemaps are disabled, and this is a telnet debug dession, account for breakpoint offsets
219
            if (sourceLocation && this.launchConfiguration?.enableSourceMaps === false && !this.launchConfiguration.enableDebugProtocol) {
11!
220
                sourceLocation.lineNumber = this.getLineNumberOffsetByBreakpoints(sourceLocation.filePath, sourceLocation.lineNumber);
×
221
            }
222

223
            if (!sourceLocation?.filePath) {
11✔
224
                //couldn't find a source location. At least send back the staging file information so the user can still debug
225
                return {
5✔
226
                    filePath: stagingFileInfo.absolutePath,
227
                    lineNumber: sourceLocation?.lineNumber || debuggerLineNumber,
25!
228
                    columnIndex: debuggerColumnNumber - 1
229
                } as SourceLocation;
230
            } else {
231
                return sourceLocation;
6✔
232
            }
233
        });
234
    }
235

236
    /**
237
     *
238
     * @param stagingDir - the path to
239
     */
240
    public async registerEntryBreakpoint(stagingDir: string) {
241
        //find the main function from the staging flder
242
        let entryPoint = await fileUtils.findEntryPoint(stagingDir);
×
243

244
        //convert entry point staging location to source location
245
        let sourceLocation = await this.getSourceLocation(entryPoint.relativePath, entryPoint.lineNumber);
×
246

247
        this.logger.info(`Registering entry breakpoint at ${sourceLocation.filePath}:${sourceLocation.lineNumber} (${entryPoint.pathAbsolute}:${entryPoint.lineNumber})`);
×
248
        //register the entry breakpoint
249
        this.breakpointManager.setBreakpoint(sourceLocation.filePath, {
×
250
            //+1 to select the first line of the function
251
            line: sourceLocation.lineNumber + 1
252
        });
253
    }
254

255
    /**
256
     * Given a debugger-relative file path, find the path to that file in the staging directory.
257
     * This supports the standard out dir, as well as component library out dirs
258
     * @param debuggerPath the path to the file which was provided by the debugger
259
     * @param stagingDir - the path to the root of the staging folder (where all of the files were copied before deployment)
260
     * @return a full path to the file in the staging directory
261
     */
262
    public async getStagingFileInfo(debuggerPath: string) {
263
        let componentLibraryIndex = fileUtils.getComponentLibraryIndexFromFileName(debuggerPath, componentLibraryPostfix);
18✔
264

265
        //the path carries a `__lib<index>` postfix, so we know exactly which component library it belongs to
266
        if (componentLibraryIndex !== undefined) {
18✔
267
            let lib = this.componentLibraryProjects.find(x => x.libraryIndex === componentLibraryIndex);
3✔
268
            if (!lib) {
3!
UNCOV
269
                throw new Error(`There is no component library with index ${componentLibraryIndex}`);
×
270
            }
271
            return this.buildStagingFileInfo(debuggerPath, lib);
3✔
272
        }
273

274
        //No `__lib<index>` postfix on the path. It's either a main-project file, or a file from a
275
        //component library that has postfixing disabled (those report device paths that look identical
276
        //to the main project's). Try the main project first.
277
        let stagingFileInfo = await this.buildStagingFileInfo(debuggerPath, this.mainProject);
15✔
278

279
        //if the file actually exists in the main project, use it
280
        if (stagingFileInfo && await fsExtra.pathExists(stagingFileInfo.absolutePath)) {
15✔
281
            return stagingFileInfo;
8✔
282
        }
283

284
        //otherwise, fall back to any component library that has postfixing disabled, since those are the
285
        //only other source of un-postfixed device paths. Use the first one that has the file on disk.
286
        for (const lib of this.componentLibraryProjects) {
7✔
287
            if (lib.enablePostfix === false) {
7✔
288
                const libStagingFileInfo = await this.buildStagingFileInfo(debuggerPath, lib);
1✔
289
                if (libStagingFileInfo && await fsExtra.pathExists(libStagingFileInfo.absolutePath)) {
1!
290
                    return libStagingFileInfo;
1✔
291
                }
292
            }
293
        }
294

295
        //nothing matched on disk; preserve prior behavior by returning the main-project result (if any)
296
        return stagingFileInfo;
6✔
297
    }
298

299
    /**
300
     * Resolve a debugger-reported path to a staging file within a specific project. Returns undefined if the
301
     * path could not be mapped into that project's staging directory.
302
     */
303
    private async buildStagingFileInfo(debuggerPath: string, project: Project) {
304
        let relativePath: string;
305

306
        //if the path starts with a scheme (i.e. pkg:/ or complib:/), we have an exact match.
307
        if (util.getFileScheme(debuggerPath)) {
19✔
308
            relativePath = util.removeFileScheme(debuggerPath);
14✔
309
        } else {
310
            relativePath = await fileUtils.findPartialFileInDirectory(debuggerPath, project.stagingDir);
5✔
311
        }
312
        if (relativePath) {
19!
313
            relativePath = fileUtils.removeLeadingSlash(
19✔
314
                fileUtils.standardizePath(relativePath)
315
            );
316
            return {
19✔
317
                relativePath: relativePath,
318
                absolutePath: s`${project.stagingDir}/${relativePath}`,
319
                project: project
320
            };
321
        } else {
322
            return undefined;
×
323
        }
324
    }
325

326
    public dispose() {
327
        util.applyDispose(this.getAllProjects());
15✔
328
    }
329
}
330

331
export interface AddProjectParams {
332
    rootDir: string;
333
    outDir: string;
334
    packagePath?: string;
335
    sourceDirs?: string[];
336
    files: Array<FileEntry>;
337
    injectRaleTrackerTask?: boolean;
338
    raleTrackerTaskFileLocation?: string;
339
    injectRdbOnDeviceComponent?: boolean;
340
    rdbFilesBasePath?: string;
341
    bsConst?: Record<string, boolean>;
342
    stagingDir?: string;
343
    enhanceREPLCompletions: boolean;
344
}
345

346
export class Project {
2✔
347
    constructor(params: AddProjectParams) {
348
        if (!params?.rootDir) {
612!
349
            throw new Error('rootDir is required');
×
350
        }
351
        this.rootDir = fileUtils.standardizePath(params.rootDir);
612✔
352

353
        if (!params?.outDir) {
612!
354
            throw new Error('outDir is required');
×
355
        }
356
        this.outDir = fileUtils.standardizePath(params.outDir);
612✔
357
        this.stagingDir = params.stagingDir ?? rokuDeploy.getOptions(this).stagingDir;
612✔
358
        this.bsConst = params.bsConst;
612✔
359
        this.sourceDirs = (params.sourceDirs ?? [])
612✔
360
            //standardize every sourcedir
361
            .map(x => fileUtils.standardizePath(x));
101✔
362
        this.injectRaleTrackerTask = params.injectRaleTrackerTask ?? false;
612✔
363
        this.raleTrackerTaskFileLocation = params.raleTrackerTaskFileLocation;
612✔
364
        this.injectRdbOnDeviceComponent = params.injectRdbOnDeviceComponent ?? false;
612✔
365
        this.rdbFilesBasePath = params.rdbFilesBasePath;
612✔
366
        this.files = params.files ?? [];
612✔
367
        this.packagePath = params.packagePath;
612✔
368
        this.enhanceREPLCompletions = params.enhanceREPLCompletions;
612✔
369
    }
370
    public rootDir: string;
371
    public outDir: string;
372
    public packagePath: string;
373
    public sourceDirs: string[];
374
    public files: Array<FileEntry>;
375
    public stagingDir: string;
376
    public fileMappings: Array<{ src: string; dest: string }>;
377

378
    /**
379
     * Absolute staging paths of every file referenced by a `<script uri="...">` tag in any staged XML
380
     * component. Roku loads these as BrightScript regardless of file extension, so they are valid
381
     * breakpoint targets even when they don't end in `.brs`. Populated during `stage()` (the single
382
     * staging-file walk in `preprocessStagingFiles`) so consumers don't have to re-scan the staging dir.
383
     */
384
    public scriptReferencedFiles = new Set<string>();
612✔
385
    public bsConst: Record<string, boolean>;
386
    public injectRaleTrackerTask: boolean;
387
    public raleTrackerTaskFileLocation: string;
388
    public injectRdbOnDeviceComponent: boolean;
389
    public rdbFilesBasePath: string;
390
    public enhanceREPLCompletions: boolean;
391

392
    /**
393
     * A BrighterScript project for the stagingDir
394
     */
395
    private stagingBscProject = new BscProjectThreaded();
612✔
396

397
    //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
398
    public get postfix() {
399
        return '';
56✔
400
    }
401

402
    private logger = logger.createLogger(`[${ProjectManager.name}]`);
612✔
403

404
    public async stage() {
405
        if (!this.fileMappings) {
18✔
406
            this.fileMappings = await this.getFileMappings();
10✔
407
        }
408

409
        //copy all project files to the staging folder
410
        await rokuDeploy.prepublishToStaging({
18✔
411
            rootDir: this.rootDir,
412
            stagingDir: this.stagingDir,
413
            files: this.fileMappings,
414
            outDir: this.outDir,
415
            //we already fetched the file mappings ourselves, so roku-deploy doesn't need to glob the files again
416
            resolveFilesArray: false
417
        });
418

419
        await this.preprocessStagingFiles();
18✔
420

421
        if (this.enhanceREPLCompletions) {
18✔
422
            //activate our background brighterscript ProgramBuilder now that the staging directory contains the final production project
423
            this.stagingBscProject.activate({
1✔
424
                rootDir: this.stagingDir,
425
                files: ['**/*'],
426
                watch: false,
427
                createPackage: false,
428
                deploy: false,
429
                copyToStaging: false,
430
                showDiagnosticsInConsole: false,
431
                logLevel: 'error',
432
                //this project is only used for file and scope lookups, so skip all validations since that takes a while and we don't care
433
                validate: false
434
            }).catch((e) => {
435
                this.logger.error('Error activating staging project.', e);
×
436
            });
437
        }
438

439
        //preload the original location of every file
440
        await this.resolveFileMappingsForSourceDirs();
18✔
441

442
        await this.transformManifestWithBsConst();
18✔
443

444
        await this.copyAndTransformRaleTrackerTask();
18✔
445

446
        await this.copyAndTransformRDB();
18✔
447
    }
448

449
    /**
450
     * Get all of the functions available for all scopes for this file.
451
     * @param relativePath path to the file relative to rootDir
452
     * @returns
453
     */
454
    public getScopeFunctionsForFile(relativePath: string) {
455
        if (this.enhanceREPLCompletions && this.stagingBscProject?.isActivated) {
×
456
            return this.stagingBscProject.getScopeFunctionsForFile({ relativePath: relativePath });
×
457
        } else {
458
            return [];
×
459
        }
460
    }
461

462
    /**
463
     * Get the range of the scope for the given position in the file
464
     * @param relativePath path to the file relative to rootDir
465
     * @param position the position in the file to get the scope range for
466
     */
467
    public async getScopeRange(relativePath: string, position: Position) {
468
        if (this.stagingBscProject?.isActivated) {
×
469
            return this.stagingBscProject.getScopeRange({ relativePath: relativePath, position: position });
×
470
        } else {
471
            return undefined;
×
472
        }
473
    }
474

475
    /**
476
     * If the project uses sourceDirs, replace every `fileMapping.src` with its original location in sourceDirs
477
     */
478
    private resolveFileMappingsForSourceDirs() {
479
        return Promise.all([
18✔
480
            this.fileMappings.map(async x => {
481
                let stagingFilePathRelative = fileUtils.getRelativePath(this.stagingDir, x.dest);
38✔
482
                let sourceDirFilePath = await fileUtils.findFirstRelativeFile(stagingFilePathRelative, this.sourceDirs);
38✔
483
                if (sourceDirFilePath) {
38!
484
                    x.src = sourceDirFilePath;
×
485
                }
486
            })
487
        ]);
488
    }
489

490
    /**
491
     * Walk every staged file once and apply all necessary rewrites for files that were moved
492
     * from a different source location:
493
     *  - .map files: rewrite `sources` paths to be relative to the new staging location
494
     *  - .brs/.xml files: rewrite the sourceMappingURL comment path to point to the staged map
495
     */
496
    private async preprocessStagingFiles() {
497
        const srcToDestMap = new Map<string, string>();
142✔
498
        const destToSrcMap = new Map<string, string>();
142✔
499
        for (const mapping of this.fileMappings) {
142✔
500
            srcToDestMap.set(mapping.src, mapping.dest);
182✔
501
            destToSrcMap.set(mapping.dest, mapping.src);
182✔
502
        }
503

504
        //reset before re-scanning
505
        this.scriptReferencedFiles.clear();
142✔
506

507
        //walk over every file
508
        const stagedFiles: string[] = (await fastGlob('**/*', { cwd: this.stagingDir, absolute: true, onlyFiles: true }))
142✔
509
            .map((f: string) => fileUtils.standardizePath(f));
2,606✔
510

511
        await Promise.all(stagedFiles.map(async (stagingFilePath: string) => {
142✔
512
            const ext = path.extname(stagingFilePath).toLowerCase();
2,606✔
513
            const originalSrcPath = destToSrcMap.get(stagingFilePath);
2,606✔
514

515
            //.map files are handled separately (they get their own JSON read), and never need the
516
            //text-content path below. Skip maps that aren't in fileMappings (generated after staging).
517
            if (ext === '.map') {
2,606✔
518
                if (originalSrcPath) {
46✔
519
                    await this.fixSourceMapSources({
45✔
520
                        stagingMapPath: stagingFilePath,
521
                        originalMapPath: originalSrcPath
522
                    });
523
                }
524
                return;
46✔
525
            }
526

527
            //read each text file at most once and share the contents between the two consumers below:
528
            // - collectScriptReferencedFiles: runs for ALL staged xml (a component may be generated during
529
            //   staging, so it isn't necessarily in fileMappings)
530
            // - fixSourceMapComment: runs only for files that were moved from a source dir (in fileMappings)
531
            //binary files need neither, so we never read them.
532
            const isXml = ext === '.xml';
2,560✔
533
            const needsCommentFix = !!originalSrcPath;
2,560✔
534
            if (Project.binaryExtensions.has(ext) || (!isXml && !needsCommentFix)) {
2,560✔
535
                return;
2,486✔
536
            }
537

538
            let contents: string;
539
            try {
74✔
540
                contents = await fsExtra.readFile(stagingFilePath, 'utf8');
74✔
541
            } catch (e) {
542
                this.logger.debug('Error reading staged file during preprocess', { stagingFilePath, error: e });
×
543
                return;
×
544
            }
545

546
            if (isXml) {
74✔
547
                this.collectScriptReferencedFiles(stagingFilePath, contents);
14✔
548
            }
549
            if (needsCommentFix) {
74✔
550
                await this.fixSourceMapComment(stagingFilePath, originalSrcPath, srcToDestMap, contents);
67✔
551
            }
552
        }));
553
    }
554

555
    /**
556
     * Parse a staged XML file's contents for `<script uri="...">` tags and add each referenced file's
557
     * absolute staging path to {@link scriptReferencedFiles}. `pkg:/`/`libpkg:/` uris resolve from the
558
     * staging root; bare relative uris resolve from the XML file's own directory. Roku loads these as
559
     * BrightScript regardless of extension, so they are valid breakpoint targets.
560
     * @param contents the already-loaded file contents (read once by the staging walk)
561
     */
562
    private collectScriptReferencedFiles(xmlStagingPath: string, contents: string) {
563
        const scriptUriRegex = /<script\b[^>]*\buri\s*=\s*"([^"]*)"[^>]*\/?>/gi;
14✔
564
        let match: RegExpExecArray;
565
        while ((match = scriptUriRegex.exec(contents)) !== null) {
14✔
566
            const uri = match[1];
8✔
567
            const protocolIndex = uri.indexOf(':/');
8✔
568
            let absolutePath: string;
569
            if (protocolIndex >= 0) {
8✔
570
                //pkg:/ or libpkg:/ — resolve from staging root
571
                const relativePath = uri.substring(protocolIndex + 2).replace(/^\//, '');
7✔
572
                absolutePath = s`${this.stagingDir}/${relativePath}`;
7✔
573
            } else {
574
                //relative path — resolve from the XML file's directory
575
                absolutePath = s`${path.resolve(path.dirname(xmlStagingPath), uri)}`;
1✔
576
            }
577
            this.scriptReferencedFiles.add(absolutePath);
8✔
578
        }
579
    }
580

581
    /**
582
     * Per-path write locks. Async file ops interleave (e.g. `preprocessStagingFiles` fans out
583
     * tasks that can target the same staging `.map`), so we queue all writes to a given path
584
     * through a single promise chain. Entries clean themselves up once their chain is idle.
585
     */
586
    private writeLocks = new Map<string, Promise<unknown>>();
612✔
587

588
    private serializeWrite<T>(filePath: string, work: () => Promise<T>): Promise<T> {
589
        const key = fileUtils.standardizePath(filePath).toLowerCase();
105✔
590
        const prev = this.writeLocks.get(key) ?? Promise.resolve();
105✔
591
        //run work whether the prior op resolved or rejected — failures upstream shouldn't poison the chain
592
        const next = prev.then(work, work);
105✔
593
        this.writeLocks.set(key, next);
105✔
594
        //drop the entry once nothing else has chained onto it
595
        void next.catch(() => { /* swallow — caller's await sees the real error */ }).then(() => {
105✔
596
            if (this.writeLocks.get(key) === next) {
105✔
597
                this.writeLocks.delete(key);
102✔
598
            }
599
        });
600
        return next;
105✔
601
    }
602

603
    /**
604
     * Serialized wrapper around `fsExtra.writeFile`. Use in place of `fsExtra.writeFile` anywhere
605
     * a path might also be written by another concurrent task in this Project.
606
     */
607
    private writeFile(filePath: string, data: Parameters<typeof fsExtra.writeFile>[1], options?: Parameters<typeof fsExtra.writeFile>[2]) {
608
        return this.serializeWrite(filePath, () => fsExtra.writeFile(filePath, data, options));
64✔
609
    }
610

611
    /**
612
     * Serialized wrapper around `fsExtra.copyFile`. The dest path is the one we serialize on,
613
     * since that's what gets written.
614
     */
615
    private copyFile(srcPath: string, destPath: string) {
616
        return this.serializeWrite(destPath, () => fsExtra.copyFile(srcPath, destPath));
41✔
617
    }
618

619
    /**
620
     * Rewrite the `sources` paths in a staged .map file so they are relative to the map's
621
     * new staging location rather than the original source directory.
622
     */
623
    private async fixSourceMapSources(params: { stagingMapPath: string; originalMapPath: string }) {
624
        const { stagingMapPath, originalMapPath } = params;
86✔
625

626
        try {
86✔
627
            const sourceMap = await fsExtra.readJsonSync(stagingMapPath) as SourceMapPayload;
86✔
628
            if (!Array.isArray(sourceMap.sources) || sourceMap.sources.length === 0) {
85✔
629
                return;
56✔
630
            }
631
            // Resolve sources relative to original map's base dir (honoring sourceRoot if present)
632
            const originalBaseDir = path.resolve(
29✔
633
                //sourceRoot should resolve relative to originalMapDir, or keep as-is when absolute path
634
                path.dirname(originalMapPath),
635
                sourceMap.sourceRoot ?? ''
87✔
636
            );
637

638
            const stagingMapDir = path.dirname(stagingMapPath);
29✔
639

640
            sourceMap.sources = sourceMap.sources.map((source) => {
29✔
641
                const absoluteSourcePath = path.resolve(originalBaseDir, source);
30✔
642
                return fileUtils.standardizePath(path.relative(stagingMapDir, absoluteSourcePath));
30✔
643
            });
644

645
            // Clear sourceRoot since sources are now relative to the map file's new location
646
            delete sourceMap.sourceRoot;
29✔
647

648
            await this.writeFile(stagingMapPath, JSON.stringify(sourceMap));
29✔
649
        } catch (e) {
650
            this.logger.error(`Error updating source map sources for '${stagingMapPath}'`, e);
1✔
651
        }
652
    }
653

654

655
    public static readonly binaryExtensions = new Set([
2✔
656
        // images
657
        '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg',
658
        '.heic', '.heif', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng',
659
        // video
660
        '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg',
661
        '.m2v', '.ts', '.mts', '.m2ts', '.vob', '.ogv', '.3gp', '.3g2',
662
        // audio
663
        '.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
664
        // fonts
665
        '.ttf', '.otf', '.woff', '.woff2', '.eot',
666
        // archives / binary containers
667
        '.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar', '.pkg', '.exe', '.dll', '.so',
668
        // documents / other binary formats
669
        '.pdf', '.psd', '.ai', '.eps', '.indd',
670
        // roku-specific
671
        '.roku', '.rdb', '.squashfs'
672
    ]);
673

674
    /**
675
     * Extracts the sourceMappingURL comment from the given file contents.
676
     *
677
     * `match[3]` is the path (which may be relative or absolute)
678
     * @param contents
679
     * @returns
680
     */
681
    public static getSourceMapComment(contents: string) {
682

683
        //https://regex101.com/r/FMRJNy/2
684
        const commentMatch = [
112✔
685
            ...contents.matchAll(/^([ \t]*(?:'|<!--)?[ \t]*)((?:\/\/)?[ \t]*[#@][ \t]*sourceMappingURL=(.+\b))(?:|-->)?/gm)
686
        ].pop();
687
        if (commentMatch) {
112✔
688
            return {
59✔
689
                /**
690
                 * The entire matched comment, including any leading whitespace and comment characters (e.g. `'` or `<!--`), which should be preserved when rewriting the comment
691
                 */
692
                fullMatch: commentMatch?.[0],
177!
693
                /**
694
                 * The leading whitespace and comment characters (e.g. `'` or `<!--`) before the actual `sourceMappingURL` text, which should be preserved when rewriting the comment
695
                 */
696
                leadingInfo: commentMatch?.[1],
177!
697
                /**
698
                 * The entire comment text without the leadingInfo (e.g. `//# sourceMappingURL=someFile.map`)
699
                 */
700
                wholeComment: commentMatch?.[2],
177!
701
                /**
702
                 * The path to the source map file (e.g. `someFile.map`)
703
                 */
704
                mapPath: commentMatch?.[3]
177!
705
            };
706
        } else {
707
            return undefined;
53✔
708
        }
709
    }
710

711
    /**
712
     * Rewrite the sourceMappingURL comment in a staged .brs or .xml file so the path points
713
     * to the map file's new staging location.
714
     *
715
     * Recognised comment forms (# and legacy @ are both accepted; // is optional for brs/xml):
716
     *   BRS:   ' [//] [#|@] sourceMappingURL=<path>
717
     *   XML:   <!-- [//] [#|@] sourceMappingURL=<path> -->
718
     *   other: // \s* [#|@] sourceMappingURL=<path>
719
     *
720
     * When rewriting, the canonical modern form is always written:
721
     *   BRS:   '//# sourceMappingURL=<path>
722
     *   XML:   <!--//# sourceMappingURL=<path> -->
723
     *   other: //# sourceMappingURL=<path>
724
     */
725
    private async fixSourceMapComment(stagingFilePath: string, originalSrcPath: string, srcToDestMap: Map<string, string>, contents: string) {
726
        try {
67✔
727
            const commentMatch = Project.getSourceMapComment(contents);
67✔
728

729
            let absoluteMapPath: string;
730

731
            if (commentMatch) {
67✔
732
                absoluteMapPath = fileUtils.standardizePath(
34✔
733
                    path.isAbsolute(commentMatch.mapPath)
34✔
734
                        ? commentMatch.mapPath
735
                        : path.resolve(path.dirname(originalSrcPath), commentMatch.mapPath)
736
                );
737

738
                //copy the sourcemap right next to our file in staging
739
                absoluteMapPath = await this.colocateSourceMap({
34✔
740
                    absoluteMapPath: absoluteMapPath,
741
                    stagingFilePath: stagingFilePath
742
                });
743

744
            } else {
745
                // No comment — check if a colocated map exists next to the original source file
746
                absoluteMapPath = fileUtils.standardizePath(originalSrcPath + '.map');
33✔
747

748
                //there is no colocated map next to the original source file
749
                if (!await fsExtra.pathExists(absoluteMapPath)) {
33✔
750
                    return;
26✔
751
                }
752

753
                //copy the sourcemap right next to our file in staging — the debugger will find it automatically
754
                await this.colocateSourceMap({
7✔
755
                    absoluteMapPath: absoluteMapPath,
756
                    stagingFilePath: stagingFilePath
757
                });
758
                return;
7✔
759
            }
760

761
            // If the map was also staged, point at its new location; otherwise point back at the original
762
            const mapTarget = srcToDestMap.get(absoluteMapPath) ?? absoluteMapPath;
34!
763
            const newRelativePath = fileUtils.standardizePath(
34✔
764
                path.relative(path.dirname(stagingFilePath), mapTarget)
765
            );
766

767
            const newComment = `${commentMatch.leadingInfo.trimEnd()}//# sourceMappingURL=${newRelativePath}`;
34✔
768
            contents = contents.replace(commentMatch.fullMatch, newComment);
34✔
769
            await this.writeFile(stagingFilePath, contents, 'utf8');
34✔
770
        } catch (e) {
771
            this.logger.error(`Error updating sourceMappingURL comment in '${stagingFilePath}'`, e);
×
772
        }
773
    }
774

775
    private async colocateSourceMap(options: { stagingFilePath: string; absoluteMapPath: string }) {
776
        //copy the sourcemap right next to our file (skip if it's already there)
777
        const stagingMapPath = `${options.stagingFilePath}.map`;
41✔
778
        if (fileUtils.standardizePath(options.absoluteMapPath) !== fileUtils.standardizePath(stagingMapPath)) {
41!
779
            await this.copyFile(options.absoluteMapPath, stagingMapPath);
41✔
780
        }
781
        await this.fixSourceMapSources({
41✔
782
            stagingMapPath: stagingMapPath,
783
            originalMapPath: options.absoluteMapPath
784
        });
785
        return stagingMapPath;
41✔
786
    }
787

788

789
    /**
790
     * Apply the bsConst transformations to the manifest file for this project
791
     */
792
    public async transformManifestWithBsConst() {
793
        if (this.bsConst) {
18✔
794
            let manifestPath = s`${this.stagingDir}/manifest`;
1✔
795
            if (await fsExtra.pathExists(manifestPath)) {
1!
796
                // Update the bs_const values in the manifest in the staging folder before side loading the channel
797
                let fileContents = (await fsExtra.readFile(manifestPath)).toString();
1✔
798
                fileContents = this.updateManifestBsConsts(this.bsConst, fileContents);
1✔
799
                await this.writeFile(manifestPath, fileContents);
1✔
800
            }
801
        }
802
    }
803

804
    public updateManifestBsConsts(consts: Record<string, boolean>, fileContents: string): string {
805
        let bsConstLine: string;
806
        let missingConsts: string[] = [];
5✔
807
        let lines = fileContents.split(/\r?\n/g);
5✔
808

809
        let newLine: string;
810
        //loop through the lines until we find the bs_const line if it exists
811
        for (const line of lines) {
5✔
812
            if (line.toLowerCase().startsWith('bs_const')) {
53✔
813
                bsConstLine = line;
5✔
814
                newLine = line;
5✔
815
                break;
5✔
816
            }
817
        }
818

819
        if (bsConstLine) {
5!
820
            // update the consts in the manifest and check for missing consts
821
            missingConsts = Object.keys(consts).reduce((results, key) => {
5✔
822
                let match = new RegExp('(' + key + '\\s*=\\s*[true|false]+[^\\S\\r\\n]*\)', 'i').exec(bsConstLine);
7✔
823
                if (match) {
7!
824
                    newLine = newLine.replace(match[1], `${key}=${consts[key].toString()}`);
7✔
825
                } else {
826
                    results.push(key);
×
827
                }
828

829
                return results;
7✔
830
            }, []);
831

832
            // check for consts that where not in the manifest
833
            if (missingConsts.length > 0) {
5!
834
                throw new Error(`The following bs_const keys were not defined in the channel's manifest:\n\n${missingConsts.join(',\n')}`);
×
835
            } else {
836
                // update the manifest contents
837
                return fileContents.replace(bsConstLine, newLine);
5✔
838
            }
839
        } else {
840
            throw new Error('bs_const was defined in the launch.json but not in the channel\'s manifest');
×
841
        }
842
    }
843

844
    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✔
845
    public static RALE_TRACKER_ENTRY = 'vscode_rale_tracker_entry';
2✔
846
    /**
847
     * Search the project files for the comment "' vscode_rale_tracker_entry" and replace it with the code needed to start the TrackerTask.
848
     */
849
    public async copyAndTransformRaleTrackerTask() {
850
        // inject the tracker task into the staging files if we have everything we need
851
        if (!this.injectRaleTrackerTask || !this.raleTrackerTaskFileLocation) {
31✔
852
            return;
18✔
853
        }
854
        try {
13✔
855
            await fsExtra.copy(this.raleTrackerTaskFileLocation, s`${this.stagingDir}/components/TrackerTask.xml`);
13✔
856
            this.logger.log('Tracker task successfully injected');
13✔
857
            // Search for the tracker task entry injection point
858
            const trackerReplacementResult = await replaceInFile({
13✔
859
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
860
                from: new RegExp(`^.*'\\s*${Project.RALE_TRACKER_ENTRY}.*$`, 'mig'),
861
                to: (match: string) => {
862
                    // Strip off the comment
863
                    let startOfLine = match.substring(0, match.indexOf(`'`));
12✔
864
                    if (/[\S]/.exec(startOfLine)) {
12✔
865
                        // There was some form of code before the tracker entry
866
                        // append and use single line syntax
867
                        startOfLine += ': ';
6✔
868
                    }
869
                    return `${startOfLine}${Project.RALE_TRACKER_TASK_CODE}`;
12✔
870
                }
871
            });
872
            const injectedFiles = trackerReplacementResult
13✔
873
                .filter(result => result.hasChanged)
26✔
874
                .map(result => result.file);
12✔
875

876
            if (injectedFiles.length === 0) {
13✔
877
                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}"`);
1✔
878
            }
879
        } catch (err) {
880
            console.error(err);
×
881
        }
882
    }
883

884
    public static RDB_ODC_NODE_CODE = `if true = CreateObject("roAppInfo").IsDev() then m.vscode_rdb_odc_node = createObject("roSGNode", "RTA_OnDeviceComponent") ' RDB OnDeviceComponent`;
2✔
885
    public static RDB_ODC_ENTRY = 'vscode_rdb_on_device_component_entry';
2✔
886
    /**
887
     * 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.
888
     */
889
    public async copyAndTransformRDB() {
890
        // inject the on device component into the staging files if we have everything we need
891
        if (!this.injectRdbOnDeviceComponent || !this.rdbFilesBasePath) {
34✔
892
            return;
19✔
893
        }
894
        try {
15✔
895

896
            let files: string[] = await fastGlob(
15✔
897
                //fast-glob requires forward slashes, so convert any backslashes in the provided path to forward slashes before globbing
898
                `${this.rdbFilesBasePath}/**/*`.replace(/[\\/]+/g, '/'),
899
                {
900
                    cwd: './',
901
                    absolute: false,
902
                    followSymbolicLinks: true
903
                }
904
            );
905
            for (let filePathAbsolute of files) {
15✔
906
                const promises = [];
28✔
907
                //only include files (i.e. skip directories)
908
                if (await util.isFile(filePathAbsolute)) {
28!
909
                    const relativePath = s`${filePathAbsolute}`.replace(s`${this.rdbFilesBasePath}`, '');
28✔
910
                    const destinationPath = s`${this.stagingDir}/${relativePath}`;
28✔
911
                    promises.push(fsExtra.copy(filePathAbsolute, destinationPath));
28✔
912
                }
913
                await Promise.all(promises);
28✔
914
                this.logger.log('RDB OnDeviceComponent successfully injected');
28✔
915
            }
916

917
            // Search for the tracker task entry injection point
918
            const replacementResult = await replaceInFile({
15✔
919
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
920
                from: new RegExp(`^.*'\\s*${Project.RDB_ODC_ENTRY}.*$`, 'mig'),
921
                to: (match: string) => {
922
                    // Strip off the comment
923
                    let startOfLine = match.substring(0, match.indexOf(`'`));
12✔
924
                    if (/[\S]/.exec(startOfLine)) {
12✔
925
                        // There was some form of code before the tracker entry
926
                        // append and use single line syntax
927
                        startOfLine += ': ';
6✔
928
                    }
929
                    return `${startOfLine}${Project.RDB_ODC_NODE_CODE}`;
12✔
930
                }
931
            });
932
            const injectedFiles = replacementResult
14✔
933
                .filter(result => result.hasChanged)
42✔
934
                .map(result => result.file);
12✔
935

936
            if (injectedFiles.length === 0) {
14✔
937
                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}"`);
2✔
938
            }
939
        } catch (err) {
940
            console.error(err);
1✔
941
        }
942
    }
943

944
    /**
945
     *
946
     * @param stagingPath
947
     */
948
    public async zipPackage(params: { retainStagingFolder: boolean }) {
949
        const options = rokuDeploy.getOptions({
3✔
950
            ...this,
951
            ...params
952
        });
953

954
        let packagePath = this.packagePath;
3✔
955
        if (!this.packagePath) {
3✔
956
            //make sure the output folder exists
957
            await fsExtra.ensureDir(options.outDir);
2✔
958

959
            packagePath = rokuDeploy.getOutputZipFilePath(options);
2✔
960
        }
961

962
        //ensure the manifest file exists in the staging folder
963
        if (!await rokuDeployUtil.fileExistsCaseInsensitive(`${options.stagingDir}/manifest`)) {
3!
964
            throw new Error(`Cannot zip package: missing manifest file in "${options.stagingDir}"`);
×
965
        }
966

967
        // create a zip of the staging folder
968
        await rokuDeploy.zipFolder(options.stagingDir, packagePath, undefined, [
3✔
969
            '**/*',
970
            //exclude sourcemap files (they're large and can't be parsed on-device anyway...)
971
            '!**/*.map'
972
        ]);
973

974
        //delete the staging folder unless told to retain it.
975
        if (options.retainStagingDir !== true) {
3!
976
            await fsExtra.remove(options.stagingDir);
×
977
        }
978
    }
979

980
    /**
981
     * Get the file paths from roku-deploy, and ensure the dest paths are absolute
982
     * (`dest` paths are relative in later versions of roku-deploy)
983
     */
984
    protected async getFileMappings() {
985
        let fileMappings = await rokuDeploy.getFilePaths(this.files, this.rootDir, true, this.stagingDir);
19✔
986
        return fileMappings;
19✔
987
    }
988

989
    public dispose() {
990
        this.stagingBscProject?.dispose?.();
×
991
    }
992
}
993

994
export interface ComponentLibraryConstructorParams extends AddProjectParams {
995
    outFile: string;
996
    libraryIndex: number;
997
    install?: boolean;
998
    enablePostfix?: boolean;
999
}
1000

1001
export class ComponentLibraryProject extends Project {
2✔
1002
    constructor(params: ComponentLibraryConstructorParams) {
1003
        super(params);
294✔
1004
        this.outFile = params.outFile;
294✔
1005
        this.libraryIndex = params.libraryIndex;
294✔
1006
        this.install = params.install ?? false;
294✔
1007
        this.enablePostfix = params.enablePostfix ?? true;
294✔
1008
    }
1009
    public outFile: string;
1010
    public libraryIndex: number;
1011
    public install: boolean;
1012
    /**
1013
     * Should this component library's `.brs` files be renamed with a `__lib<index>` postfix during staging?
1014
     * Postfixing is how the debugger maps a device-reported file path back to the component library it came
1015
     * from. When disabled, file names are left untouched (useful when a library loads files by a fixed name
1016
     * at runtime), at the cost of degraded source mapping for this library's files while debugging.
1017
     */
1018
    public enablePostfix: boolean;
1019
    /**
1020
     * The name of the component library that this project represents. This is loaded during `this.computeOutFileName`
1021
     */
1022
    public name: string;
1023

1024
    /**
1025
     * Takes a component Library and checks the outFile for replaceable values pulled from the libraries manifest
1026
     * @param manifestPath the path to the manifest file to check
1027
     */
1028
    private async computeOutFileName(manifestPath: string) {
1029
        let regexp = /\$\{([\w\d_]*)\}/;
10✔
1030
        let renamingMatch: RegExpExecArray;
1031
        let manifestValues = await util.convertManifestToObject(manifestPath);
10✔
1032
        if (!manifestValues) {
10✔
1033
            throw new Error(`Cannot find manifest file at "${manifestPath}"\n\nCould not complete automatic component library naming.`);
1✔
1034
        }
1035

1036
        //load the component libary name from the manifest
1037
        this.name = manifestValues.sg_component_libs_provided || manifestValues.bs_libs_provided;
9✔
1038

1039
        // search the outFile for replaceable values such as ${title}
1040
        while ((renamingMatch = regexp.exec(this.outFile))) {
9✔
1041

1042
            // replace the replaceable key with the manifest value
1043
            let manifestVariableName = renamingMatch[1];
5✔
1044
            let manifestVariableValue = manifestValues[manifestVariableName];
5✔
1045
            if (manifestVariableValue) {
5!
1046
                this.outFile = this.outFile.replace(renamingMatch[0], manifestVariableValue);
5✔
1047
            } else {
1048
                throw new Error(`Cannot find manifest value:\n"${manifestVariableName}"\n\nCould not complete automatic component library naming.`);
×
1049
            }
1050
        }
1051
    }
1052

1053
    public async stage() {
1054
        /*
1055
         Compute the file mappings now (i.e. don't let the parent class compute them).
1056
         This must be done BEFORE finding the manifest file location.
1057
         */
1058
        this.fileMappings = await this.getFileMappings();
9✔
1059

1060
        let expectedManifestDestPath = fileUtils.standardizePath(`${this.stagingDir}/manifest`).toLowerCase();
9✔
1061
        //find the file entry with the `dest` value of `${stagingDir}/manifest` (case insensitive)
1062
        let manifestFileEntry = this.fileMappings.find(x => x.dest.toLowerCase() === expectedManifestDestPath);
9✔
1063
        if (manifestFileEntry) {
9!
1064
            //read the manifest from `src` since nothing has been copied to staging yet
1065
            await this.computeOutFileName(manifestFileEntry.src);
9✔
1066
        } else {
1067
            throw new Error(`Could not find manifest path for component library at '${this.rootDir}'`);
×
1068
        }
1069
        let fileNameWithoutExtension = path.basename(this.outFile, path.extname(this.outFile));
9✔
1070

1071
        let defaultStagingDir = this.stagingDir;
9✔
1072

1073
        //compute the staging folder path.
1074
        this.stagingDir = s`${this.outDir}/${fileNameWithoutExtension}`;
9✔
1075

1076
        /*
1077
          The fileMappings were created using the default stagingDir (because we need the manifest path
1078
          to compute the out file name and staging path), so we need to replace the default stagingDir
1079
          with the actual stagingDir.
1080
         */
1081
        for (let fileMapping of this.fileMappings) {
9✔
1082
            fileMapping.dest = fileUtils.replaceCaseInsensitive(fileMapping.dest, defaultStagingDir, this.stagingDir);
10✔
1083
        }
1084

1085
        return super.stage();
9✔
1086
    }
1087

1088
    /**
1089
     * The text used as a postfix for every brs file so we can accurately track the location of the files
1090
     * back to their original component library whenever the debugger truncates the file path.
1091
     */
1092
    public get postfix() {
1093
        //when postfixing is disabled, return an empty postfix so all postfix-aware logic (file renaming,
1094
        //breakpoint path matching, source-location resolution) becomes a no-op for this library
1095
        return this.enablePostfix ? `${componentLibraryPostfix}${this.libraryIndex}` : '';
14✔
1096
    }
1097

1098
    public async postfixFiles() {
1099
        //postfixing disabled for this library; leave file names and `uri=` references untouched
1100
        if (!this.enablePostfix) {
3✔
1101
            return;
1✔
1102
        }
1103
        let pathDetails = {};
2✔
1104
        await Promise.all(this.fileMappings.map(async (fileMapping) => {
2✔
1105
            let relativePath = fileUtils.removeLeadingSlash(
×
1106
                fileUtils.getRelativePath(this.stagingDir, fileMapping.dest)
1107
            );
1108
            let postfixedPath = fileUtils.postfixFilePath(relativePath, this.postfix, ['.brs']);
×
1109
            if (postfixedPath !== relativePath) {
×
1110
                // Rename the brs files to include the postfix namespacing tag
1111
                await fsExtra.move(fileMapping.dest, path.join(this.stagingDir, postfixedPath));
×
1112
                // Add to the map of original paths and the new paths
1113
                pathDetails[postfixedPath] = relativePath;
×
1114
            }
1115
        }));
1116

1117
        // Update all the file name references in the library to the new file names
1118
        await replaceInFile({
2✔
1119
            files: [
1120
                path.join(this.stagingDir, '**/*.xml'),
1121
                path.join(this.stagingDir, '**/*.brs')
1122
            ],
1123
            from: /uri\s*=\s*"(.+)\.brs"/gi,
1124
            to: (match: string) => {
1125
                // only alter file ending if it is a) pkg:/ url or b) relative url
1126
                let isPkgUrl = !!/^uri\s*=\s*"(pkg:|libpkg:)\//i.exec(match);
8✔
1127
                let isRelativeUrl = !/:\//i.exec(match);
8✔
1128
                if (isPkgUrl || isRelativeUrl) {
8✔
1129
                    return match.replace('.brs', this.postfix + '.brs');
6✔
1130
                } else {
1131
                    return match;
2✔
1132
                }
1133
            }
1134
        });
1135
    }
1136
}
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