diff --git a/package-lock.json b/package-lock.json index 7844b543..1f618f89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", "roku-deploy": "^3.10.2", - "semver": "^7.3.5", + "semver": "^7.5.3", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", "source-map": "^0.7.4", @@ -4611,8 +4611,9 @@ } }, "node_modules/semver": { - "version": "7.3.5", - "license": "ISC", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -8386,7 +8387,9 @@ } }, "semver": { - "version": "7.3.5", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", "requires": { "lru-cache": "^6.0.0" } diff --git a/package.json b/package.json index 0e571ea7..5e021bba 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", "roku-deploy": "^3.10.2", - "semver": "^7.3.5", + "semver": "^7.5.3", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", "source-map": "^0.7.4", diff --git a/src/LaunchConfiguration.ts b/src/LaunchConfiguration.ts index e06f392e..c5489a54 100644 --- a/src/LaunchConfiguration.ts +++ b/src/LaunchConfiguration.ts @@ -287,4 +287,10 @@ export interface ComponentLibraryConfiguration { * This is an absolute path to the TrackerTask.xml file to be injected into the component library during a debug session. */ raleTrackerTaskFileLocation: string; + /** + * If true, turn on ECP tracking, unless unsupported, then turn on 8080 tracking. + * If false, turn off both. + * TODO - this isn't actually implemented yet + */ + rendezvousTracking: boolean; } diff --git a/src/RendezvousTracker.spec.ts b/src/RendezvousTracker.spec.ts index 70fcf4a5..2e936a73 100644 --- a/src/RendezvousTracker.spec.ts +++ b/src/RendezvousTracker.spec.ts @@ -3,6 +3,7 @@ const sinon = createSandbox(); import { assert, expect } from 'chai'; import type { RendezvousHistory } from './RendezvousTracker'; import { RendezvousTracker } from './RendezvousTracker'; +import { SceneGraphDebugCommandController } from './SceneGraphDebugCommandController'; describe('BrightScriptFileUtils ', () => { let rendezvousTracker: RendezvousTracker; @@ -268,24 +269,130 @@ describe('BrightScriptFileUtils ', () => { afterEach(() => { sinon.restore(); rendezvousTrackerMock.restore(); + rendezvousTracker?.destroy(); }); - describe('hasMinVersion ', () => { + describe('isEcpRendezvousTrackingSupported ', () => { it('works', () => { - expect(rendezvousTracker.hasMinVersion('11.0.0')).to.equal(false); - expect(rendezvousTracker.hasMinVersion('11.5.0')).to.equal(true); - expect(rendezvousTracker.hasMinVersion('12.0.1')).to.equal(true); + rendezvousTracker['deviceInfo']['software-version'] = '11.0.0'; + expect(rendezvousTracker.isEcpRendezvousTrackingSupported).to.be.false; + + rendezvousTracker['deviceInfo']['software-version'] = '11.5.0'; + expect(rendezvousTracker.isEcpRendezvousTrackingSupported).to.be.true; + + rendezvousTracker['deviceInfo']['software-version'] = '12.0.1'; + expect(rendezvousTracker.isEcpRendezvousTrackingSupported).to.be.true; + }); + }); + + describe('on', () => { + it('supports unsubscribing', () => { + const spy = sinon.spy(); + const disconnect = rendezvousTracker.on('rendezvous', spy); + rendezvousTracker['emit']('rendezvous', {}); + rendezvousTracker['emit']('rendezvous', {}); + expect(spy.callCount).to.eql(2); + disconnect(); + expect(spy.callCount).to.eql(2); + //disconnect again to fix code coverage + delete rendezvousTracker['emitter']; + disconnect(); + }); + }); + + describe('getIsTelnetRendezvousTrackingEnabled', () => { + async function doTest(rawResponse: string, expectedValue: boolean) { + const stub = sinon.stub(SceneGraphDebugCommandController.prototype, 'logrendezvous').returns(Promise.resolve({ + result: { + rawResponse: 'on\n' + } + } as any)); + expect( + await rendezvousTracker.getIsTelnetRendezvousTrackingEnabled() + ).to.be.true; + stub.restore(); + } + + it('handles various responses', async () => { + await doTest('on', true); + await doTest('on\n', true); + await doTest('on \n', true); + await doTest('off', false); + await doTest('off\n', false); + await doTest('off \n', false); + }); + + it('does not crash on missing response', async () => { + await doTest(undefined, true); + }); + + it('logs an error', async () => { + const stub = sinon.stub(rendezvousTracker['logger'], 'warn'); + sinon.stub( + SceneGraphDebugCommandController.prototype, 'logrendezvous' + ).returns( + Promise.reject(new Error('crash')) + ); + await rendezvousTracker.getIsTelnetRendezvousTrackingEnabled(); + expect(stub.called).to.be.true; + }); + }); + + describe('startEcpPingTimer', () => { + it('only sets the timer once', () => { + rendezvousTracker.startEcpPingTimer(); + const ecpPingTimer = rendezvousTracker['ecpPingTimer']; + rendezvousTracker.startEcpPingTimer(); + //the timer reference shouldn't have changed + expect(ecpPingTimer).to.eql(rendezvousTracker['ecpPingTimer']); + //stop the timer + rendezvousTracker.stopEcpPingTimer(); + expect(rendezvousTracker['ecpPingTimer']).to.be.undefined; + //stopping while stopped is a noop + rendezvousTracker.stopEcpPingTimer(); }); }); describe('pingEcpRendezvous ', () => { - it('works', async() => { + it('works', async () => { sinon.stub(rendezvousTracker, 'getEcpRendezvous').returns(Promise.resolve({ 'trackingEnabled': true, 'items': [{ 'id': '1403', 'startTime': '97771301', 'endTime': '97771319', 'lineNumber': '11', 'file': 'pkg:/components/Tasks/GetSubReddit.brs' }, { 'id': '1404', 'startTime': '97771322', 'endTime': '97771322', 'lineNumber': '15', 'file': 'pkg:/components/Tasks/GetSubReddit.brs' }] })); await rendezvousTracker.pingEcpRendezvous(); expect(rendezvousTracker['rendezvousHistory']).to.eql({ 'hitCount': 2, 'occurrences': { 'pkg:/components/Tasks/GetSubReddit.brs': { 'occurrences': { '11': { 'clientLineNumber': 11, 'clientPath': '/components/Tasks/GetSubReddit.brs', 'hitCount': 1, 'totalTime': 0.018, 'type': 'lineInfo' }, '15': { 'clientLineNumber': 15, 'clientPath': '/components/Tasks/GetSubReddit.brs', 'hitCount': 1, 'totalTime': 0, 'type': 'lineInfo' } }, 'hitCount': 2, 'totalTime': 0.018, 'type': 'fileInfo', 'zeroCostHitCount': 1 } }, 'totalTime': 0.018, 'type': 'historyInfo', 'zeroCostHitCount': 1 }); }); }); + describe('activateEcpTracking', () => { + beforeEach(() => { + sinon.stub(rendezvousTracker, 'pingEcpRendezvous').returns(Promise.resolve()); + sinon.stub(rendezvousTracker, 'startEcpPingTimer').callsFake(() => { }); + sinon.stub(rendezvousTracker, 'toggleEcpRendezvousTracking').returns(Promise.resolve(true)); + }); + + it('does not activate if telnet and ecp are both off', async () => { + sinon.stub(rendezvousTracker, 'getIsEcpRendezvousTrackingEnabled').returns(Promise.resolve(false)); + sinon.stub(rendezvousTracker, 'getIsTelnetRendezvousTrackingEnabled').returns(Promise.resolve(false)); + expect( + await rendezvousTracker.activateEcpTracking() + ).to.be.false; + }); + + it('activates if telnet is enabled but ecp is disabled', async () => { + sinon.stub(rendezvousTracker, 'getIsEcpRendezvousTrackingEnabled').returns(Promise.resolve(false)); + sinon.stub(rendezvousTracker, 'getIsTelnetRendezvousTrackingEnabled').returns(Promise.resolve(true)); + expect( + await rendezvousTracker.activateEcpTracking() + ).to.be.true; + }); + + it('activates if telnet is disabled but ecp is enabled', async () => { + sinon.stub(rendezvousTracker, 'getIsEcpRendezvousTrackingEnabled').returns(Promise.resolve(true)); + sinon.stub(rendezvousTracker, 'getIsTelnetRendezvousTrackingEnabled').returns(Promise.resolve(false)); + expect( + await rendezvousTracker.activateEcpTracking() + ).to.be.true; + }); + }); + describe('processLog ', () => { it('filters out all rendezvous log lines', async () => { rendezvousTrackerMock.expects('emit').withArgs('rendezvous').once(); diff --git a/src/RendezvousTracker.ts b/src/RendezvousTracker.ts index da326a47..5760967a 100644 --- a/src/RendezvousTracker.ts +++ b/src/RendezvousTracker.ts @@ -3,12 +3,12 @@ import * as path from 'path'; import * as replaceLast from 'replace-last'; import type { SourceLocation } from './managers/LocationManager'; import { logger } from './logging'; -import axios from 'axios'; import { SceneGraphDebugCommandController } from './SceneGraphDebugCommandController'; import * as xml2js from 'xml2js'; import * as request from 'request'; +import { util } from './util'; +import * as semver from 'semver'; -const minVersion = '11.5.0'; const telnetRendezvousString = 'on\n'; export class RendezvousTracker { @@ -29,6 +29,13 @@ export class RendezvousTracker { private rendezvousHistory: RendezvousHistory; private ecpTrackingEnabled = false; + /** + * Determine if the current Roku device supports the ECP rendezvous tracking feature + */ + public get isEcpRendezvousTrackingSupported() { + return semver.gte(this.deviceInfo['software-version'] as string, '11.5.0'); + } + public logger = logger.createLogger(`[${RendezvousTracker.name}]`); public on(eventname: 'rendezvous', handler: (output: RendezvousHistory) => void); public on(eventName: string, handler: (payload: any) => void) { @@ -76,10 +83,21 @@ export class RendezvousTracker { this.emit('rendezvous', this.rendezvousHistory); } + private ecpPingTimer: NodeJS.Timer; + public startEcpPingTimer(): void { - setInterval(() => { - void this.pingEcpRendezvous(); - }, 1000); + if (!this.ecpPingTimer) { + this.ecpPingTimer = setInterval(() => { + void this.pingEcpRendezvous(); + }, 1000); + } + } + + public stopEcpPingTimer() { + if (this.ecpPingTimer) { + clearInterval(this.ecpPingTimer); + this.ecpPingTimer = undefined; + } } public async pingEcpRendezvous(): Promise { @@ -96,55 +114,60 @@ export class RendezvousTracker { this.emit('rendezvous', this.rendezvousHistory); } - public async checkForEcpTracking(): Promise { - let currVersion = this.deviceInfo['software-version']; - let host = this.deviceInfo.host; - let telnetRendezvousTracking = false; - if (this.hasMinVersion(currVersion)) { - let ecpData = await this.getEcpRendezvous(); - this.ecpTrackingEnabled = ecpData.trackingEnabled; - if (!this.ecpTrackingEnabled) { - let connection = new SceneGraphDebugCommandController(host); - try { - let logRendezvousResponse = await connection.logrendezvous('status'); - telnetRendezvousTracking = logRendezvousResponse.result.rawResponse.endsWith(telnetRendezvousString); - } catch (error) { - this.logger.warn('An error occurred getting logRendezvous'); - } - await connection.end(); - } - if (telnetRendezvousTracking || this.ecpTrackingEnabled) { - // Toggle ECP tracking off and on to clear the log and then continue tracking - let untrack = await this.toggleEcpTracking('untrack'); - let track = await this.toggleEcpTracking('track'); - this.ecpTrackingEnabled = untrack && track; - } - if (this.ecpTrackingEnabled) { - this.logger.log('ecp rendezvous logging is enabled'); - this.startEcpPingTimer(); - } + /** + * Determine if rendezvous tracking is enabled via the 8080 telnet command + */ + public async getIsTelnetRendezvousTrackingEnabled() { + let host = this.deviceInfo.host as string; + let sgDebugCommandController = new SceneGraphDebugCommandController(host); + try { + let logRendezvousResponse = await sgDebugCommandController.logrendezvous('status'); + return logRendezvousResponse.result.rawResponse?.trim()?.toLowerCase() === 'on'; + } catch (error) { + this.logger.warn('An error occurred getting logRendezvous'); + } finally { + await sgDebugCommandController.end(); } } - public hasMinVersion(currVersion: string): boolean { - const currVersionArr = currVersion.split('.').map((n) => parseInt(n)); - const minimumVersionArr = minVersion.split('.').map((n) => parseInt(n)); + /** + * Determine if rendezvous tracking is enabled via the ECP command + */ + public async getIsEcpRendezvousTrackingEnabled() { + let ecpData = await this.getEcpRendezvous(); + return ecpData.trackingEnabled; + } + + public async activateEcpTracking(): Promise { + //ECP tracking not supported, return early + if (!this.isEcpRendezvousTrackingSupported) { + return; + } - // Compare major, minor, and patch versions, loop if versions are equal - for (let i = 0; i < 3; i++) { - if (currVersionArr[i] < minimumVersionArr[i]) { - return false; - } else if (currVersionArr[i] > minimumVersionArr[i]) { - return true; - } + let isTelnetRendezvousTrackingEnabled = false; + this.ecpTrackingEnabled = await this.getIsEcpRendezvousTrackingEnabled(); + isTelnetRendezvousTrackingEnabled = await this.getIsTelnetRendezvousTrackingEnabled(); + + if (this.ecpTrackingEnabled || isTelnetRendezvousTrackingEnabled) { + // Toggle ECP tracking off and on to clear the log and then continue tracking + let untrack = await this.toggleEcpRendezvousTracking('untrack'); + let track = await this.toggleEcpRendezvousTracking('track'); + this.ecpTrackingEnabled = untrack && track; + } + if (this.ecpTrackingEnabled) { + this.logger.log('ecp rendezvous logging is enabled'); + this.startEcpPingTimer(); } - return true; + return this.ecpTrackingEnabled; } + /** + * Get the response from an ECP sgrendezvous request from the Roku + */ public async getEcpRendezvous(): Promise { // Send rendezvous query to ECP - const rendezvousQuery = await axios.get(`http://${this.deviceInfo.host}:${this.deviceInfo.remotePort}/query/sgrendezvous`); - let rendezvousQueryData = rendezvousQuery.data; + const rendezvousQuery = await util.httpGet(`http://${this.deviceInfo.host}:${this.deviceInfo.remotePort}/query/sgrendezvous`); + let rendezvousQueryData = rendezvousQuery.body; let ecpData: EcpRendezvousData = { trackingEnabled: false, items: [] @@ -174,19 +197,21 @@ export class RendezvousTracker { return ecpData; } - public async toggleEcpTracking(toggle: string): Promise { - // Send rendezvous query to ECP - const url = `http://${this.deviceInfo.host}:${this.deviceInfo.remotePort}/sgrendezvous/${toggle}`; - const data = ''; - let results: boolean = await new Promise((resolve, reject) => { - request.post(url, { body: data }, (err, resp, body) => { - if (err) { - return reject(err); - } - resolve(true); - }); - }); - return results; + /** + * Enable/Disable ECP Rendezvous tracking on the Roku device + * @returns true if successful, false if there was an issue setting the value + */ + public async toggleEcpRendezvousTracking(toggle: 'track' | 'untrack'): Promise { + try { + const response = await util.httpPost( + `http://${this.deviceInfo.host}:${this.deviceInfo.remotePort}/sgrendezvous/${toggle}`, + //not sure if we need this, but it works...so probably better to just leave it here + { body: '' } + ); + return true; + } catch (e) { + return false; + } } /** @@ -371,6 +396,13 @@ export class RendezvousTracker { private getTime(duration?: string): number { return duration ? parseFloat(duration) : 0.000; } + + /** + * Destroy/tear down this class + */ + public destroy() { + this.stopEcpPingTimer(); + } } export interface RendezvousHistory { diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts index d964e4a2..86456f83 100644 --- a/src/debugSession/BrightScriptDebugSession.spec.ts +++ b/src/debugSession/BrightScriptDebugSession.spec.ts @@ -48,6 +48,9 @@ describe('BrightScriptDebugSession', () => { fsExtra.emptydirSync(tempDir); sinon.restore(); + //prevent calling DebugSession.shutdown() because that calls process.kill(), which would kill the test session + sinon.stub(DebugSession.prototype, 'shutdown').returns(null); + try { session = new BrightScriptDebugSession(); } catch (e) { @@ -649,8 +652,6 @@ describe('BrightScriptDebugSession', () => { (session as any).launchConfiguration = { retainStagingFolder: false }; - //stub the super shutdown call so it doesn't kill the test session - sinon.stub(DebugSession.prototype, 'shutdown').returns(null); await session.shutdown(); expect(stub.callCount).to.equal(2); diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 8cad084b..0c7e0fd4 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -290,8 +290,10 @@ export class BrightScriptDebugSession extends BaseDebugSession { util.log(`Connecting to Roku via ${this.enableDebugProtocol ? 'the BrightScript debug protocol' : 'telnet'} at ${this.launchConfiguration.host}`); this.rendezvousTracker = new RendezvousTracker(this.deviceInfo); - // start rendezvous tracking - await this.rendezvousTracker.checkForEcpTracking(); + + // start ECP rendezvous tracking (if possible) + await this.rendezvousTracker.activateEcpTracking(); + this.createRokuAdapter(this.launchConfiguration.host, this.rendezvousTracker); if (!this.enableDebugProtocol) { //connect to the roku debug via telnet @@ -1308,6 +1310,9 @@ export class BrightScriptDebugSession extends BaseDebugSession { private async _shutdown(errorMessage?: string): Promise { try { + // + this.rendezvousTracker?.destroy?.(); + //if configured, delete the staging directory if (!this.launchConfiguration.retainStagingFolder) { const stagingFolders = this.projectManager?.getStagingFolderPaths() ?? []; diff --git a/src/util.ts b/src/util.ts index 4f1dcd8e..636fc8e0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -449,9 +449,9 @@ class Util { /** * Do an http POST request */ - public httpPost(url: string) { + public httpPost(url: string, options?: requestType.CoreOptions) { return new Promise((resolve, reject) => { - request.post(url, (err, response) => { + request.post(url, options, (err, response) => { return err ? reject(err) : resolve(response); }); });