diff --git a/package-lock.json b/package-lock.json index 66707e48..bb07ca31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "postman-request": "^2.88.1-postman.32", "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", - "roku-deploy": "^3.11.3", + "roku-deploy": "^3.12.0", "semver": "^7.5.4", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", @@ -4898,9 +4898,9 @@ } }, "node_modules/roku-deploy": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.11.3.tgz", - "integrity": "sha512-vHb/YL45LWrD+hOAGO9GGhZW5GE6W5I/bSGLCO/JgyqhvRq/MoKdeFuEUgrztDhaKgODQIjGj7/DJaUO0Vx+IA==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.12.0.tgz", + "integrity": "sha512-YiCZeQ+sEmFW9ZfXtMNH+/CBSHQ5deNZYWONM+s6gCEQsrz7kCMFPj5YEdgfqW+d2b8G1ve9GELHcSt2FsfM8g==", "dependencies": { "chalk": "^2.4.2", "dateformat": "^3.0.3", @@ -9608,9 +9608,9 @@ } }, "roku-deploy": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.11.3.tgz", - "integrity": "sha512-vHb/YL45LWrD+hOAGO9GGhZW5GE6W5I/bSGLCO/JgyqhvRq/MoKdeFuEUgrztDhaKgODQIjGj7/DJaUO0Vx+IA==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.12.0.tgz", + "integrity": "sha512-YiCZeQ+sEmFW9ZfXtMNH+/CBSHQ5deNZYWONM+s6gCEQsrz7kCMFPj5YEdgfqW+d2b8G1ve9GELHcSt2FsfM8g==", "requires": { "chalk": "^2.4.2", "dateformat": "^3.0.3", diff --git a/package.json b/package.json index 23e5f78f..3bce690e 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "postman-request": "^2.88.1-postman.32", "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", - "roku-deploy": "^3.11.3", + "roku-deploy": "^3.12.0", "semver": "^7.5.4", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", diff --git a/src/LaunchConfiguration.ts b/src/LaunchConfiguration.ts index 05d7d0b3..839154a7 100644 --- a/src/LaunchConfiguration.ts +++ b/src/LaunchConfiguration.ts @@ -292,6 +292,39 @@ export interface LaunchConfiguration extends DebugProtocol.LaunchRequestArgument * @default false */ deleteDevChannelBeforeInstall: boolean; + + /** + * Task to run instead of roku-deploy to produce the .zip file that will be uploaded to the Roku. + */ + packageTask: string; + + /** + * Path to the .zip that will be uploaded to the Roku + */ + packagePath: string; + + /** + * Overrides for values used during the roku-deploy zip upload process, like the route and various form data. You probably don't need to change these.. + */ + packageUploadOverrides?: { + /** + * The route to use for uploading to the Roku device. Defaults to 'plugin_install' + * @default 'plugin_install' + */ + route: string; + + /** + * A dictionary of form fields to be included in the package upload request. Set a value to null to delete from the form + */ + formData: Record<string, any>; + }; + + /** + * Should the ChannelPublishedEvent be emitted. This is a hack for when certain roku devices become locked up as a result of this event + * being emitted. You probably don't need to set this + * @default true + */ + emitChannelPublishedEvent?: boolean; } export interface ComponentLibraryConfiguration { diff --git a/src/RendezvousTracker.spec.ts b/src/RendezvousTracker.spec.ts index ff2b58db..997b21ff 100644 --- a/src/RendezvousTracker.spec.ts +++ b/src/RendezvousTracker.spec.ts @@ -291,6 +291,20 @@ describe('BrightScriptFileUtils ', () => { rendezvousTracker['deviceInfo'].softwareVersion = '12.0.1'; expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.true; }); + + it('does not crash when softwareVersion is corrupt or missing', () => { + rendezvousTracker['deviceInfo'].softwareVersion = ''; + expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false; + + rendezvousTracker['deviceInfo'].softwareVersion = 'notAVersion'; + expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false; + + rendezvousTracker['deviceInfo'].softwareVersion = undefined; + expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false; + + rendezvousTracker['deviceInfo'] = undefined; + expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false; + }); }); describe('on', () => { diff --git a/src/RendezvousTracker.ts b/src/RendezvousTracker.ts index dba1b442..e1dd210f 100644 --- a/src/RendezvousTracker.ts +++ b/src/RendezvousTracker.ts @@ -38,7 +38,11 @@ export class RendezvousTracker { * Determine if the current Roku device supports the ECP rendezvous tracking feature */ public get doesHostSupportEcpRendezvousTracking() { - return semver.gte(this.deviceInfo.softwareVersion, '11.5.0'); + let softwareVersion = this.deviceInfo?.softwareVersion; + if (!semver.valid(softwareVersion)) { + softwareVersion = '0.0.0'; + } + return semver.gte(softwareVersion, '11.5.0'); } public logger = logger.createLogger(`[${RendezvousTracker.name}]`); diff --git a/src/adapters/TelnetAdapter.ts b/src/adapters/TelnetAdapter.ts index 2619d23d..b740b67a 100644 --- a/src/adapters/TelnetAdapter.ts +++ b/src/adapters/TelnetAdapter.ts @@ -5,7 +5,7 @@ import { rokuDeploy } from 'roku-deploy'; import { PrintedObjectParser } from '../PrintedObjectParser'; import type { BSDebugDiagnostic } from '../CompileErrorProcessor'; import { CompileErrorProcessor } from '../CompileErrorProcessor'; -import type { RendezvousHistory, RendezvousTracker } from '../RendezvousTracker'; +import type { RendezvousTracker } from '../RendezvousTracker'; import type { ChanperfData } from '../ChanperfTracker'; import { ChanperfTracker } from '../ChanperfTracker'; import type { SourceLocation } from '../managers/LocationManager'; diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts index 395b0a3a..e4df696c 100644 --- a/src/debugSession/BrightScriptDebugSession.spec.ts +++ b/src/debugSession/BrightScriptDebugSession.spec.ts @@ -7,17 +7,19 @@ import type { DebugProtocol } from 'vscode-debugprotocol/lib/debugProtocol'; import { DebugSession } from 'vscode-debugadapter'; import { BrightScriptDebugSession } from './BrightScriptDebugSession'; import { fileUtils } from '../FileUtils'; -import type { EvaluateContainer, StackFrame, TelnetAdapter } from '../adapters/TelnetAdapter'; -import { PrimativeType } from '../adapters/TelnetAdapter'; -import { defer } from '../util'; +import type { EvaluateContainer, StackFrame } from '../adapters/TelnetAdapter'; +import { PrimativeType, TelnetAdapter } from '../adapters/TelnetAdapter'; +import { defer, util } from '../util'; import { HighLevelType } from '../interfaces'; import type { LaunchConfiguration } from '../LaunchConfiguration'; import type { SinonStub } from 'sinon'; import { DiagnosticSeverity, util as bscUtil, standardizePath as s } from 'brighterscript'; -import { DefaultFiles } from 'roku-deploy'; +import { DefaultFiles, rokuDeploy } from 'roku-deploy'; import type { AddProjectParams, ComponentLibraryConstructorParams } from '../managers/ProjectManager'; import { ComponentLibraryProject, Project } from '../managers/ProjectManager'; import { RendezvousTracker } from '../RendezvousTracker'; +import { ClientToServerCustomEventName, isCustomRequestEvent } from './Events'; +import { EventEmitter } from 'eventemitter3'; const sinon = sinonActual.createSandbox(); const tempDir = s`${__dirname}/../../.tmp`; @@ -99,10 +101,10 @@ describe('BrightScriptDebugSession', () => { } }; rokuAdapter = { - on: () => { - return () => { - }; - }, + emitter: new EventEmitter(), + on: TelnetAdapter.prototype.on, + once: TelnetAdapter.prototype.once, + emit: TelnetAdapter.prototype['emit'], activate: () => Promise.resolve(), registerSourceLocator: (a, b) => { }, setConsoleOutput: (a) => { }, @@ -148,6 +150,93 @@ describe('BrightScriptDebugSession', () => { sinon.restore(); }); + it('supports external zipping process', async () => { + //write some project files + fsExtra.outputFileSync(`${rootDir}/source/main.brs`, ` + sub main() + print "hello" + end sub + `); + fsExtra.outputFileSync(`${rootDir}/manifest`, ''); + + const packagePath = s`${tempDir}/custom/app.zip`; + + //init the session + session.initializeRequest({} as any, {} as any); + + //set a breakpoint in main + await session.setBreakPointsRequest({} as any, { + source: { + path: s`${rootDir}/source/main.brs` + }, + breakpoints: [{ + line: 2 + }] + }); + + sinon.stub(rokuDeploy, 'getDeviceInfo').returns(Promise.resolve({ + developerEnabled: true + })); + sinon.stub(util, 'dnsLookup').callsFake((host) => Promise.resolve(host)); + + let sendEvent = session.sendEvent.bind(session); + sinon.stub(session, 'sendEvent').callsFake((event) => { + if (isCustomRequestEvent(event)) { + void rokuDeploy.zipFolder(session['launchConfiguration'].stagingDir, packagePath).then(() => { + //pretend we are the client and send a response back + session.emit(ClientToServerCustomEventName.customRequestEventResponse, { + requestId: event.body.requestId + }); + }); + } else { + //call through + return sendEvent(event); + } + }); + sinon.stub(session as any, 'connectRokuAdapter').callsFake(() => { + sinon.stub(session['rokuAdapter'], 'connect').returns(Promise.resolve()); + session['rokuAdapter'].connected = true; + return Promise.resolve(session['rokuAdapter']); + }); + + const publishStub = sinon.stub(session.rokuDeploy, 'publish').callsFake(() => { + //emit the app-ready event + (session['rokuAdapter'] as TelnetAdapter)['emit']('app-ready'); + + return Promise.resolve({ + message: 'success', + results: [] + }); + }); + + await session.launchRequest({} as any, { + cwd: tempDir, + //where the source files reside + rootDir: rootDir, + //where roku-debug should put the staged files (and inject breakpoints) + stagingDir: `${stagingDir}/staging`, + //the name of the task that should be run to create the zip (doesn't matter for this test...we're going to intercept it anyway) + packageTask: 'custom-build', + //where the packageTask will be placing the compiled zip + packagePath: packagePath, + packageUploadOverrides: { + route: '1234', + formData: { + one: 'two', + three: null + } + } + } as Partial<LaunchConfiguration> as LaunchConfiguration); + + expect(publishStub.getCall(0).args[0].packageUploadOverrides).to.eql({ + route: '1234', + formData: { + one: 'two', + three: null + } + }); + }); + describe('evaluateRequest', () => { it('resets local var counter on suspend', async () => { const stub = sinon.stub(session['rokuAdapter'], 'evaluate').callsFake(x => { diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index a8120578..f7aed639 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -39,7 +39,9 @@ import { ChanperfEvent, DebugServerLogOutputEvent, ChannelPublishedEvent, - PopupMessageEvent + PopupMessageEvent, + CustomRequestEvent, + ClientToServerCustomEventName } from './Events'; import type { LaunchConfiguration, ComponentLibraryConfiguration } from '../LaunchConfiguration'; import { FileManager } from '../managers/FileManager'; @@ -208,6 +210,31 @@ export class BrightScriptDebugSession extends BaseDebugSession { this.logger.trace('[showPopupMessage]', severity, message); this.sendEvent(new PopupMessageEvent(message, severity, modal)); } + + private static requestIdSequence = 0; + + private async sendCustomRequest<T = any>(name: string, data: T) { + const requestId = BrightScriptDebugSession.requestIdSequence++; + const responsePromise = new Promise<any>((resolve, reject) => { + this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => { + if (response.requestId === requestId) { + if (response.error) { + throw response.error; + } else { + resolve(response); + } + } + }); + }); + this.sendEvent( + new CustomRequestEvent({ + requestId: requestId, + name: name, + ...data ?? {} + })); + await responsePromise; + } + /** * Get the cwd from the launchConfiguration, or default to process.cwd() */ @@ -223,6 +250,9 @@ export class BrightScriptDebugSession extends BaseDebugSession { * @returns */ private normalizeLaunchConfig(config: LaunchConfiguration) { + config.cwd ??= process.cwd(); + config.outDir ??= s`${config.cwd}/out`; + config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`; config.componentLibrariesPort ??= 8080; config.packagePort ??= 80; config.remotePort ??= 8060; @@ -230,12 +260,13 @@ export class BrightScriptDebugSession extends BaseDebugSession { config.controlPort ??= 8081; config.brightScriptConsolePort ??= 8085; config.stagingDir ??= config.stagingFolderPath; + config.emitChannelPublishedEvent ??= true; return config; } public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) { - this.logger.log('[launchRequest] begin'); + //send the response right away so the UI immediately shows the debugger toolbar this.sendResponse(response); @@ -354,9 +385,12 @@ export class BrightScriptDebugSession extends BaseDebugSession { await this.publish(); - this.sendEvent(new ChannelPublishedEvent( - this.launchConfiguration - )); + //hack for certain roku devices that lock up when this event is emitted (no idea why!). + if (this.launchConfiguration.emitChannelPublishedEvent) { + this.sendEvent(new ChannelPublishedEvent( + this.launchConfiguration + )); + } //tell the adapter adapter that the channel has been launched. await this.rokuAdapter.activate(); @@ -397,7 +431,7 @@ export class BrightScriptDebugSession extends BaseDebugSession { //if we are at a breakpoint, continue await this.rokuAdapter.continue(); //kill the app on the roku - await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort); + // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort); //convert a hostname to an ip address const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl); //send the deep link http request @@ -501,8 +535,7 @@ export class BrightScriptDebugSession extends BaseDebugSession { } const isConnected = this.rokuAdapter.once('app-ready'); - //publish the package to the target Roku - const publishPromise = this.rokuDeploy.publish({ + const options: RokuDeployOptions = { ...this.launchConfiguration, //typing fix logLevel: LogLevelPriority[this.logger.logLevel], @@ -511,8 +544,18 @@ export class BrightScriptDebugSession extends BaseDebugSession { //necessary for capturing compile errors from the protocol (has no effect on telnet) remoteDebugConnectEarly: false, //we don't want to fail if there were compile errors...we'll let our compile error processor handle that - failOnCompileError: true - }).then(() => { + failOnCompileError: true, + //pass any upload form overrides the client may have configured + packageUploadOverrides: this.launchConfiguration.packageUploadOverrides + }; + //if packagePath is specified, use that info instead of outDir and outFile + if (this.launchConfiguration.packagePath) { + options.outDir = path.dirname(this.launchConfiguration.packagePath); + options.outFile = path.basename(this.launchConfiguration.packagePath); + } + + //publish the package to the target Roku + const publishPromise = this.rokuDeploy.publish(options).then(() => { packageIsPublished = true; }).catch(async (e) => { const statusCode = e?.results?.response?.statusCode; @@ -533,7 +576,7 @@ export class BrightScriptDebugSession extends BaseDebugSession { //if it hasn't connected after 5 seconds, it probably will never connect. await Promise.race([ isConnected, - util.sleep(10000) + util.sleep(10_000) ]); this.logger.log('Finished racing promises'); //if the adapter is still not connected, then it will probably never connect. Abort. @@ -620,7 +663,8 @@ export class BrightScriptDebugSession extends BaseDebugSession { raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation, injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent, rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath, - stagingDir: this.launchConfiguration.stagingDir + stagingDir: this.launchConfiguration.stagingDir, + packagePath: this.launchConfiguration.packagePath }); util.log('Moving selected files to staging area'); @@ -638,23 +682,45 @@ export class BrightScriptDebugSession extends BaseDebugSession { await this.breakpointManager.writeBreakpointsForProject(this.projectManager.mainProject); } - //create zip package from staging folder - util.log('Creating zip archive from project sources'); - await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true }); + if (this.launchConfiguration.packageTask) { + util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`); + await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask }); + + const options = { + ...this.launchConfiguration + } as any as RokuDeployOptions; + //if packagePath is specified, use that info instead of outDir and outFile + if (this.launchConfiguration.packagePath) { + options.outDir = path.dirname(this.launchConfiguration.packagePath); + options.outFile = path.basename(this.launchConfiguration.packagePath); + } + const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options); + + if (!fsExtra.pathExistsSync(packagePath)) { + return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`); + } + } else { + //create zip package from staging folder + util.log('Creating zip archive from project sources'); + await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true }); + } } /** * Accepts custom events and requests from the extension * @param command name of the command to execute */ - protected customRequest(command: string) { + protected customRequest(command: string, response: DebugProtocol.Response, args: any) { if (command === 'rendezvous.clearHistory') { this.rokuAdapter.clearRendezvousHistory(); - } - if (command === 'chanperf.clearHistory') { + } else if (command === 'chanperf.clearHistory') { this.rokuAdapter.clearChanperfHistory(); + + } else if (command === 'customRequestEventResponse') { + this.emit('customRequestEventResponse', args); } + this.sendResponse(response); } /** @@ -1098,6 +1164,7 @@ export class BrightScriptDebugSession extends BaseDebugSession { let deferred = defer<void>(); if (args.context === 'repl' && !this.enableDebugProtocol && args.expression.trim().startsWith('>')) { this.clearState(); + this.rokuAdapter.clearCache(); const expression = args.expression.replace(/^\s*>\s*/, ''); this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression }); (this.rokuAdapter as TelnetAdapter).requestPipeline.client.write(`${expression}\r\n`); diff --git a/src/debugSession/Events.ts b/src/debugSession/Events.ts index be29bacf..66813d8f 100644 --- a/src/debugSession/Events.ts +++ b/src/debugSession/Events.ts @@ -158,6 +158,30 @@ export function isChannelPublishedEvent(event: any): event is ChannelPublishedEv return !!event && event.event === ChannelPublishedEvent.name; } +/** + * Event that asks the client to execute a command. + */ +export class CustomRequestEvent<T = any, R = T & { name: string; requestId: number }> extends CustomEvent<R> { + constructor(body: R) { + super(body); + } +} + +/** + * Is the object a `CustomRequestEvent` + */ +export function isCustomRequestEvent(event: any): event is CustomRequestEvent { + return !!event && event.event === CustomRequestEvent.name; +} + +export function isExecuteTaskCustomRequest(event: any): event is CustomRequestEvent<{ task: string }> { + return !!event && event.event === CustomRequestEvent.name && event.body.name === 'executeTask'; +} + +export enum ClientToServerCustomEventName { + customRequestEventResponse = 'customRequestEventResponse' +} + export enum StoppedEventReason { step = 'step', breakpoint = 'breakpoint', diff --git a/src/managers/ProjectManager.spec.ts b/src/managers/ProjectManager.spec.ts index cf1c4d44..ab9579fc 100644 --- a/src/managers/ProjectManager.spec.ts +++ b/src/managers/ProjectManager.spec.ts @@ -666,6 +666,17 @@ describe('Project', () => { expect(fsExtra.pathExistsSync(`${tempPath}/extracted/source/main.brs`)).to.be.true; expect(fsExtra.pathExistsSync(`${tempPath}/extracted/source/main.brs.map`)).to.be.false; }); + + it('uses "packagePath" when specified', async () => { + fsExtra.outputFileSync(`${project.stagingDir}/manifest`, '#stuff'); + fsExtra.outputFileSync(`${project.stagingDir}/source/main.brs`, 'sub main() : end sub'); + project.packagePath = s`${tempPath}/package/path.zip`; + await project.zipPackage({ retainStagingFolder: true }); + + await decompress(project.packagePath, `${tempPath}/extracted`); + expect(fsExtra.pathExistsSync(`${tempPath}/extracted/manifest`)).to.be.true; + expect(fsExtra.pathExistsSync(`${tempPath}/extracted/source/main.brs`)).to.be.true; + }); }); }); diff --git a/src/managers/ProjectManager.ts b/src/managers/ProjectManager.ts index bd0c2748..7eb544e2 100644 --- a/src/managers/ProjectManager.ts +++ b/src/managers/ProjectManager.ts @@ -37,6 +37,7 @@ export class ProjectManager { public launchConfiguration: { enableSourceMaps?: boolean; enableDebugProtocol?: boolean; + packagePath: string; }; public logger = logger.createLogger('[ProjectManager]'); @@ -219,6 +220,7 @@ export class ProjectManager { export interface AddProjectParams { rootDir: string; outDir: string; + packagePath?: string; sourceDirs?: string[]; files: Array<FileEntry>; injectRaleTrackerTask?: boolean; @@ -246,9 +248,11 @@ export class Project { this.injectRdbOnDeviceComponent = params.injectRdbOnDeviceComponent ?? false; this.rdbFilesBasePath = params.rdbFilesBasePath; this.files = params.files ?? []; + this.packagePath = params.packagePath; } public rootDir: string; public outDir: string; + public packagePath: string; public sourceDirs: string[]; public files: Array<FileEntry>; public stagingDir: string; @@ -471,16 +475,19 @@ export class Project { * * @param stagingPath */ - public async zipPackage(params: { retainStagingFolder: true }) { + public async zipPackage(params: { retainStagingFolder: boolean }) { const options = rokuDeploy.getOptions({ ...this, ...params }); - //make sure the output folder exists - await fsExtra.ensureDir(options.outDir); + let packagePath = this.packagePath; + if (!this.packagePath) { + //make sure the output folder exists + await fsExtra.ensureDir(options.outDir); - let zipFilePath = rokuDeploy.getOutputZipFilePath(options); + packagePath = rokuDeploy.getOutputZipFilePath(options); + } //ensure the manifest file exists in the staging folder if (!await rokuDeployUtil.fileExistsCaseInsensitive(`${options.stagingDir}/manifest`)) { @@ -488,7 +495,7 @@ export class Project { } // create a zip of the staging folder - await rokuDeploy.zipFolder(options.stagingDir, zipFilePath, undefined, [ + await rokuDeploy.zipFolder(options.stagingDir, packagePath, undefined, [ '**/*', //exclude sourcemap files (they're large and can't be parsed on-device anyway...) '!**/*.map' diff --git a/src/util.spec.ts b/src/util.spec.ts index f0e5b2c7..15263dc5 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -121,6 +121,10 @@ describe('Util', () => { assert.equal(util.getFileScheme('/images/channel-poster_hd.png'), null); assert.equal(util.getFileScheme('ages/channel-poster_hd.png'), null); }); + + it('should support file schemes with underscores', () => { + assert.equal(util.getFileScheme('thing_with_underscores:/source/lib.brs'), 'thing_with_underscores:'); + }); }); describe('convertManifestToObject', () => { diff --git a/src/util.ts b/src/util.ts index ad8be3d4..e5b797c9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -68,7 +68,7 @@ class Util { * @param filePath */ public getFileScheme(filePath: string): string | null { - return url.parse(filePath).protocol; + return /^([\w_-]+:)/.exec(filePath)?.[1]?.toLowerCase(); } /**