From 59775a22f05d34b92329b4348026d7a483d9c038 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Thu, 13 Feb 2025 12:50:14 -0400 Subject: [PATCH 01/10] Started a new RokuECP class to centerlize ECP request processing --- src/RokuECP.ts | 106 +++++++++++++++++++ src/debugSession/BrightScriptDebugSession.ts | 4 +- src/debugSession/ecpRegistryUtils.spec.ts | 57 +++++++--- src/debugSession/ecpRegistryUtils.ts | 52 +-------- src/util.ts | 4 +- 5 files changed, 156 insertions(+), 67 deletions(-) create mode 100644 src/RokuECP.ts diff --git a/src/RokuECP.ts b/src/RokuECP.ts new file mode 100644 index 00000000..84dddc20 --- /dev/null +++ b/src/RokuECP.ts @@ -0,0 +1,106 @@ + +import { util } from './util'; +import type * as requestType from 'request'; +import type { Response } from 'request'; +import * as r from 'postman-request'; +const request = r as typeof requestType; + + +export class RokuECP { + + private async doRequest(route: string, options: BaseOptions): Promise { + const url = `http://${options.host}:${options.remotePort ?? 8060}/${route.replace(/^\//, '')}`; + return util.httpGet(url, options.requestOptions); + } + + public async getRegistry(options: BaseOptions & { appId: string }): Promise { + let result = await this.doRequest(`query/registry/${options.appId}`, options); + return this.processRegistry(result); + } + + private async processRegistry(response: Response): Promise { + if (typeof response.body === 'string') { + try { + let parsed = await util.parseXml(response.body) as RegistryAsJson; + const status = parsed['plugin-registry'].status[0]; + + if (status === 'OK') { + let registry = parsed['plugin-registry'].registry?.[0]; + let sections: EcpRegistryData['sections'] = {}; + + for (const section of registry?.sections?.[0]?.section ?? []) { + if (typeof section === 'string') { + continue; + } + let sectionName = section.name[0]; + for (const item of section.items[0].item) { + sections[sectionName] ??= {}; + sections[sectionName][item.key[0]] = item.value[0]; + } + } + + return { + devId: registry?.['dev-id']?.[0], + plugins: registry?.plugins?.[0]?.split(','), + sections: sections, + spaceAvailable: registry?.['space-available']?.[0], + status: 'OK' + }; + } else { + return { + status: 'FAILED', + errorMessage: parsed?.['plugin-registry']?.error?.[0] ?? 'Unknown error' + }; + } + } catch { + //if the response is not xml, just return the body as-is + return { + status: 'FAILED', + errorMessage: response.body ?? 'Unknown error' + }; + } + } + } +} + +interface BaseOptions { + host: string; + remotePort?: number; + requestOptions?: requestType.CoreOptions; +} + +export interface EcpRegistryData { + devId?: string; + plugins?: Array; + sections?: Record>; + spaceAvailable?: string; + status: 'OK' | 'FAILED'; + errorMessage?: string; +} + +export type RokuEcpParam = Parameters[0]; + +interface RegistryAsJson { + 'plugin-registry': { + registry: [{ + 'dev-id': [string]; + plugins: [string]; + sections: [{ + section: [{ + items: [{ + item: [{ + key: [string]; + value: [string]; + }]; + }]; + name: [string]; + } | string]; + }]; + 'space-available': [string]; + }]; + status: [string]; + error?: [string]; + }; +} + +export const rokuECP = new RokuECP(); diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index da153a5a..115dcb57 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -1407,10 +1407,8 @@ export class BrightScriptDebugSession extends BaseDebugSession { if (v.type === '$$Registry' && v.childVariables.length === 0) { // This is a special scope variable used to load registry data via an ECP call - const url = `http://${this.launchConfiguration.host}:${this.launchConfiguration.remotePort}/query/registry/dev`; // Send the registry ECP call for the `dev` app as side loaded apps are always `dev` - const response = await util.httpGet(url); - await populateVariableFromRegistryEcp(response, v, this.variables, this.getEvaluateRefId.bind(this)); + await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this)); } //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable diff --git a/src/debugSession/ecpRegistryUtils.spec.ts b/src/debugSession/ecpRegistryUtils.spec.ts index 96bc5105..1ef68d30 100644 --- a/src/debugSession/ecpRegistryUtils.spec.ts +++ b/src/debugSession/ecpRegistryUtils.spec.ts @@ -4,6 +4,10 @@ import { VariableType } from '../debugProtocol/events/responses/VariablesRespons import type { AugmentedVariable } from './BrightScriptDebugSession'; import { BrightScriptDebugSession } from './BrightScriptDebugSession'; import { populateVariableFromRegistryEcp } from './ecpRegistryUtils'; +import { rokuECP } from '../RokuECP'; +import { createSandbox } from 'sinon'; + +const sinon = createSandbox(); describe('ecpRegistryUtils', () => { let session: BrightScriptDebugSession; @@ -12,6 +16,10 @@ describe('ecpRegistryUtils', () => { session = new BrightScriptDebugSession(); }); + afterEach(() => { + sinon.restore(); + }); + describe('populateVariableFromRegistryEcp', () => { let refFactory = (key: string, frameId: number) => session['getEvaluateRefId'](key, frameId); @@ -34,7 +42,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` OK @@ -42,7 +51,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'sections', @@ -84,7 +95,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` @@ -98,7 +110,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(4); expect(v.childVariables[0]).to.eql({ name: 'devId', @@ -173,7 +187,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` @@ -210,7 +225,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(4); expect(v.childVariables[0]).to.eql({ name: 'devId', @@ -334,7 +351,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` FAILED @@ -342,7 +360,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', @@ -361,7 +381,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` FAILED @@ -369,7 +390,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', @@ -388,14 +411,17 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` FAILED `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', @@ -414,10 +440,13 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: `ECP command not allowed in Limited mode.`, statusCode: 403 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', diff --git a/src/debugSession/ecpRegistryUtils.ts b/src/debugSession/ecpRegistryUtils.ts index 576fbf91..037adbe9 100644 --- a/src/debugSession/ecpRegistryUtils.ts +++ b/src/debugSession/ecpRegistryUtils.ts @@ -1,10 +1,12 @@ import { VariableType } from '../debugProtocol/events/responses/VariablesResponse'; import { util } from '../util'; +import { rokuECP } from '../RokuECP'; +import type { RokuEcpParam } from '../RokuECP'; import type { AugmentedVariable } from './BrightScriptDebugSession'; import type { Response } from 'request'; -export async function populateVariableFromRegistryEcp(response: Response, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { - let registryData = await convertRegistryEcpResponseToScope(response); +export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'getRegistry'>, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { + let registryData = await rokuECP.getRegistry(options); if (registryData.status === 'OK') { // Add registry data to variable list @@ -125,52 +127,6 @@ export async function populateVariableFromRegistryEcp(response: Response, v: Aug } } -async function convertRegistryEcpResponseToScope(response: Response): Promise { - if (typeof response.body === 'string') { - try { - let parsed = await util.parseXml(response.body) as RegistryAsJson; - - const status = parsed['plugin-registry'].status[0]; - - if (status === 'OK') { - - let registry = parsed['plugin-registry'].registry?.[0]; - - let sections: EcpRegistryData['sections'] = {}; - for (const section of registry?.sections?.[0]?.section ?? []) { - if (typeof section === 'string') { - continue; - } - let sectionName = section.name[0]; - for (const item of section.items[0].item) { - sections[sectionName] ??= {}; - sections[sectionName][item.key[0]] = item.value[0]; - } - } - - return { - devId: registry?.['dev-id']?.[0], - plugins: registry?.plugins?.[0]?.split(','), - sections: sections, - spaceAvailable: registry?.['space-available']?.[0], - status: 'OK' - }; - } else { - return { - status: 'FAILED', - errorMessage: parsed?.['plugin-registry']?.error?.[0] ?? 'Unknown error' - }; - } - } catch { - //if the response is not xml, just return the body as-is - return { - status: 'FAILED', - errorMessage: response.body - }; - } - } -} - export interface EcpRegistryData { devId?: string; plugins?: Array; diff --git a/src/util.ts b/src/util.ts index 87dded56..4743923b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -425,9 +425,9 @@ class Util { /** * Do an http GET request */ - public httpGet(url: string) { + public httpGet(url: string, options?: requestType.CoreOptions) { return new Promise((resolve, reject) => { - request.get(url, (err, response) => { + request.get(url, options, (err, response) => { return err ? reject(err) : resolve(response); }); }); From dc9a70c73270606364a37580537d79153c7b4e8f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Thu, 13 Feb 2025 15:03:39 -0400 Subject: [PATCH 02/10] Some inital code for app shutdown flows --- src/RokuECP.ts | 124 ++++++++++++++++--- src/debugSession/BrightScriptDebugSession.ts | 43 +++++++ 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/src/RokuECP.ts b/src/RokuECP.ts index 84dddc20..3f08202e 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -1,13 +1,8 @@ - import { util } from './util'; import type * as requestType from 'request'; import type { Response } from 'request'; -import * as r from 'postman-request'; -const request = r as typeof requestType; - export class RokuECP { - private async doRequest(route: string, options: BaseOptions): Promise { const url = `http://${options.host}:${options.remotePort ?? 8060}/${route.replace(/^\//, '')}`; return util.httpGet(url, options.requestOptions); @@ -25,7 +20,7 @@ export class RokuECP { const status = parsed['plugin-registry'].status[0]; if (status === 'OK') { - let registry = parsed['plugin-registry'].registry?.[0]; + const registry = parsed['plugin-registry'].registry?.[0]; let sections: EcpRegistryData['sections'] = {}; for (const section of registry?.sections?.[0]?.section ?? []) { @@ -61,6 +56,70 @@ export class RokuECP { } } } + + public async getAppState(options: BaseOptions & { appId: string }): Promise { + let result = await this.doRequest(`query/app-status/${options.appId}`, options); + return this.processAppState(result); + } + + private async processAppState(response: Response): Promise { + if (typeof response.body === 'string') { + try { + let parsed = await util.parseXml(response.body) as AppStateAsJson; + const status = parsed['app-state'].status[0]; + if (status === 'OK') { + const appState = parsed['app-state']; + return { + appId: appState['app-id']?.[0], + appDevId: appState['app-dev-id']?.[0], + appTitle: appState['app-title']?.[0], + appVersion: appState['app-version']?.[0], + state: appState.state?.[0] ?? 'unknown', + status: 'OK' + }; + } else { + return { + status: 'FAILED', + errorMessage: parsed['app-state']?.error?.[0] ?? 'Unknown error' + }; + } + } catch { + //if the response is not xml, just return the body as-is + return { + status: 'FAILED', + errorMessage: response.body ?? 'Unknown error' + }; + } + } + } + + public async exitApp(options: BaseOptions & { appId: string }): Promise { + let result = await this.doRequest(`exit-app/${options.appId}`, options); + return this.processExitApp(result); + } + + private async processExitApp(response: Response): Promise { + if (typeof response.body === 'string') { + try { + let parsed = await util.parseXml(response.body) as ExitAppAsJson; + const status = parsed['exit-app'].status[0]; + if (status === 'OK') { + return { status: 'OK' }; + } else { + return { + status: 'FAILED', + errorMessage: parsed?.['exit-app']?.error?.[0] ?? 'Unknown error' + }; + } + } catch { + //if the response is not xml, just return the body as-is + return { + status: 'FAILED', + errorMessage: response.body ?? 'Unknown error' + }; + } + } + } } interface BaseOptions { @@ -69,15 +128,6 @@ interface BaseOptions { requestOptions?: requestType.CoreOptions; } -export interface EcpRegistryData { - devId?: string; - plugins?: Array; - sections?: Record>; - spaceAvailable?: string; - status: 'OK' | 'FAILED'; - errorMessage?: string; -} - export type RokuEcpParam = Parameters[0]; interface RegistryAsJson { @@ -103,4 +153,48 @@ interface RegistryAsJson { }; } +export interface EcpRegistryData { + devId?: string; + plugins?: Array; + sections?: Record>; + spaceAvailable?: string; + state?: string; + status: 'OK' | 'FAILED'; + errorMessage?: string; +} +interface AppStateAsJson { + 'app-state': { + 'app-id': [string]; + 'app-title': [string]; + 'app-version': [string]; + 'app-dev-id': [string]; + state: ['active' | 'background' | 'inactive']; + status: [string]; + error?: [string]; + }; +} + +export interface EcpAppStateData { + appId?: string; + appTitle?: string; + appVersion?: string; + appDevId?: string; + state?: 'active' | 'background' | 'inactive' | 'unknown'; + status: 'OK' | 'FAILED'; + errorMessage?: string; +} + +interface ExitAppAsJson { + 'exit-app': { + status: [string]; + error?: [string]; + }; +} + +export interface EcpExitAppData { + status: 'OK' | 'FAILED'; + errorMessage?: string; +} + + export const rokuECP = new RokuECP(); diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 115dcb57..3b9066e5 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -58,6 +58,7 @@ import { interfaces, components, events } from 'brighterscript/dist/roku-types'; import { globalCallables } from 'brighterscript/dist/globalCallables'; import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool'; import { populateVariableFromRegistryEcp } from './ecpRegistryUtils'; +import { rokuECP } from '../RokuECP'; const diagnosticSource = 'roku-debug'; @@ -2036,11 +2037,53 @@ export class BrightScriptDebugSession extends BaseDebugSession { this.rokuAdapter.removeAllListeners(); } await this.rokuAdapter.destroy(); + await this.ensureAppIsInactive(); this.rokuAdapterDeferred = defer(); } await this.launchRequest(response, args.arguments as LaunchConfiguration); } + private exitAppTimeout: 5000; + private async ensureAppIsInactive() { + const startTime = Date.now(); + + while (true) { + if (Date.now() - startTime > this.exitAppTimeout) { + return; + } + + try { + let appStateResult = await rokuECP.getAppState({ + host: this.launchConfiguration.host, + remotePort: this.launchConfiguration.remotePort, + appId: 'dev', + requestOptions: { timeout: 300 } + }); + + const state = appStateResult.state?.toLowerCase(); + + if (state === 'active' || state === 'background') { + // Suspends or terminates an app that is running: + // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background). + // If the app supports Instant Resume and is running in the background or the app does not support Instant Resume and is running, sending this command terminates the app. + // This means that we might need to send this command twice to terminate the app. + await rokuECP.exitApp({ + host: this.launchConfiguration.host, + remotePort: this.launchConfiguration.remotePort, + appId: 'dev', + requestOptions: { timeout: 300 } + }); + } else if (state === 'inactive') { + return; + } + } catch (e) { + this.logger.error('Error attempting to exit application', e); + } + + await util.sleep(200); + } + } + /** * Used to track whether the entry breakpoint has already been handled */ From f1571ec25d7f506dc0e6312472ca3f70da39dcb6 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 10:01:24 -0400 Subject: [PATCH 03/10] Started adding tests for RokuECP --- src/RokuECP.spec.ts | 213 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/RokuECP.spec.ts diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts new file mode 100644 index 00000000..5f0841f0 --- /dev/null +++ b/src/RokuECP.spec.ts @@ -0,0 +1,213 @@ +import * as assert from 'assert'; +import { expect } from 'chai'; +import * as fsExtra from 'fs-extra'; +import * as getPort from 'get-port'; +import * as net from 'net'; +import * as path from 'path'; +import type { BSDebugDiagnostic } from './CompileErrorProcessor'; +import * as dedent from 'dedent'; +import { util } from './util'; +import { util as bscUtil } from 'brighterscript'; +import { createSandbox } from 'sinon'; +import { describe } from 'mocha'; +import { get } from 'request'; +import type { EcpRegistryData } from './RokuECP'; +import { rokuECP } from './RokuECP'; +const sinon = createSandbox(); + +beforeEach(() => { + sinon.restore(); +}); + +describe('RokuECP', () => { + + describe('getRegistry', () => { + it('calls doRequest with correct route and options', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + appId: 'dev' + }; + + let stub = sinon.stub(rokuECP as any, 'doRequest').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP.getRegistry(options); + expect(stub.getCall(0).args).to.eql(['query/registry/dev', options]); + }); + + }); + + describe('parseRegistry', () => { + describe('non-error responses', () => { + it('handles ok response with no other properties', async () => { + let response = { + body: ` + + OK + Plugin dev not found + + `, + statusCode: 200 + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + devId: undefined, + plugins: undefined, + spaceAvailable: undefined, + sections: {}, + status: 'OK' + } as EcpRegistryData); + }); + + it('handles ok response with empty sections', async () => { + let response = { + body: ` + + + + 12345 + 12,34,dev + 28075 + + + OK + + `, + statusCode: 200 + }; + + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + devId: '12345', + plugins: ['12', '34', 'dev'], + spaceAvailable: '28075', + sections: {}, + status: 'OK' + } as EcpRegistryData); + }); + + it('handles ok response with populated sections', async () => { + let response = { + body: ` + + + + 12345 + dev + 32590 + +
+ section One + + + first key in section one + value one section one + + +
+
+ section Two + + + first key in section two + value one section two + + + second key in section two + value two section two + + +
+
+
+ OK +
+ ` + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + devId: '12345', + plugins: ['dev'], + sections: { + 'section One': { + 'first key in section one': 'value one section one' + }, + 'section Two': { + 'first key in section two': 'value one section two', + 'second key in section two': 'value two section two' + } + }, + spaceAvailable: '32590', + status: 'OK' + } as EcpRegistryData); + }); + }); + + describe('error responses', () => { + it('handles not in dev mode', async () => { + let response = { + body: ` + + FAILED + Plugin dev not found + + `, + statusCode: 200 + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + status: 'FAILED', + errorMessage: 'Plugin dev not found' + } as EcpRegistryData); + }); + + it('handles device not keyed', async () => { + let response = { + body: ` + + FAILED + Device not keyed + + `, + statusCode: 200 + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + status: 'FAILED', + errorMessage: 'Device not keyed' + } as EcpRegistryData); + }); + + it('handles failed status with missing error', async () => { + let response = { + body: ` + + FAILED + + `, + statusCode: 200 + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + status: 'FAILED', + errorMessage: 'Unknown error' + } as EcpRegistryData); + }); + + it('handles error response without xml', async () => { + let response = { + body: `ECP command not allowed in Limited mode.`, + statusCode: 403 + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + status: 'FAILED', + errorMessage: 'ECP command not allowed in Limited mode.' + } as EcpRegistryData); + }); + }); + }); +}); From 586ae24dd5de89f26386732372a9bd204450e79a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 10:44:29 -0400 Subject: [PATCH 04/10] doRequest tests --- src/RokuECP.spec.ts | 83 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index 5f0841f0..e77eddcc 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -1,26 +1,83 @@ -import * as assert from 'assert'; import { expect } from 'chai'; -import * as fsExtra from 'fs-extra'; -import * as getPort from 'get-port'; -import * as net from 'net'; -import * as path from 'path'; -import type { BSDebugDiagnostic } from './CompileErrorProcessor'; -import * as dedent from 'dedent'; -import { util } from './util'; -import { util as bscUtil } from 'brighterscript'; import { createSandbox } from 'sinon'; import { describe } from 'mocha'; -import { get } from 'request'; import type { EcpRegistryData } from './RokuECP'; import { rokuECP } from './RokuECP'; +import { util } from './util'; + const sinon = createSandbox(); -beforeEach(() => { - sinon.restore(); -}); describe('RokuECP', () => { + beforeEach(() => { + sinon.restore(); + }); + + describe('doRequest', () => { + it('correctly builds url without leading /', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080 + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8080/query/my-route`, undefined]); + }); + + it('correctly builds url with leading /', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080 + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8080/query/my-route`, undefined]); + }); + + it('passes request options if populated', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + requestOptions: { + timeout: 1000 + } + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8080/query/my-route`, options.requestOptions]); + }); + + it('uses default port 8060 when missing in options', async () => { + let options = { + host: '1.1.1.1' + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); + }); + }); + describe('getRegistry', () => { it('calls doRequest with correct route and options', async () => { let options = { From 76f7aebf2fff928dc4d2d068641bb7945506cde8 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 11:55:59 -0400 Subject: [PATCH 05/10] Tests for app state and added enums --- src/RokuECP.spec.ts | 165 ++++++++++++++++++- src/RokuECP.ts | 53 +++--- src/debugSession/BrightScriptDebugSession.ts | 8 +- src/debugSession/ecpRegistryUtils.ts | 38 +---- 4 files changed, 195 insertions(+), 69 deletions(-) diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index e77eddcc..ac445890 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -1,8 +1,8 @@ import { expect } from 'chai'; import { createSandbox } from 'sinon'; import { describe } from 'mocha'; -import type { EcpRegistryData } from './RokuECP'; -import { rokuECP } from './RokuECP'; +import type { EcpAppStateData, EcpRegistryData } from './RokuECP'; +import { AppState, EcpStatus, rokuECP } from './RokuECP'; import { util } from './util'; const sinon = createSandbox(); @@ -115,7 +115,7 @@ describe('RokuECP', () => { plugins: undefined, spaceAvailable: undefined, sections: {}, - status: 'OK' + status: EcpStatus.ok } as EcpRegistryData); }); @@ -142,7 +142,7 @@ describe('RokuECP', () => { plugins: ['12', '34', 'dev'], spaceAvailable: '28075', sections: {}, - status: 'OK' + status: EcpStatus.ok } as EcpRegistryData); }); @@ -198,7 +198,7 @@ describe('RokuECP', () => { } }, spaceAvailable: '32590', - status: 'OK' + status: EcpStatus.ok } as EcpRegistryData); }); }); @@ -216,7 +216,7 @@ describe('RokuECP', () => { }; let result = await rokuECP['processRegistry'](response as any); expect(result).to.eql({ - status: 'FAILED', + status: EcpStatus.failed, errorMessage: 'Plugin dev not found' } as EcpRegistryData); }); @@ -233,7 +233,7 @@ describe('RokuECP', () => { }; let result = await rokuECP['processRegistry'](response as any); expect(result).to.eql({ - status: 'FAILED', + status: EcpStatus.failed, errorMessage: 'Device not keyed' } as EcpRegistryData); }); @@ -249,7 +249,7 @@ describe('RokuECP', () => { }; let result = await rokuECP['processRegistry'](response as any); expect(result).to.eql({ - status: 'FAILED', + status: EcpStatus.failed, errorMessage: 'Unknown error' } as EcpRegistryData); }); @@ -261,10 +261,157 @@ describe('RokuECP', () => { }; let result = await rokuECP['processRegistry'](response as any); expect(result).to.eql({ - status: 'FAILED', + status: EcpStatus.failed, errorMessage: 'ECP command not allowed in Limited mode.' } as EcpRegistryData); }); }); }); + + describe('getAppState', () => { + it('calls doRequest with correct route and options', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + appId: 'dev' + }; + + let stub = sinon.stub(rokuECP as any, 'doRequest').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP.getAppState(options); + expect(stub.getCall(0).args).to.eql(['query/app-status/dev', options]); + }); + }); + + describe('parseAppState', () => { + describe('non-error responses', () => { + it('handles ok response', async () => { + let response = { + body: ` + + + dev + my app + 10.0.0 + 12345 + active + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + appId: 'dev', + appDevId: '12345', + appTitle: 'my app', + appVersion: '10.0.0', + state: AppState.active, + status: EcpStatus.ok + } as EcpAppStateData); + }); + + it('handles ok response with unknown state', async () => { + let response = { + body: ` + + + dev + my app + 10.0.0 + 12345 + bad + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + appId: 'dev', + appDevId: '12345', + appTitle: 'my app', + appVersion: '10.0.0', + state: AppState.unknown, + status: EcpStatus.ok + } as EcpAppStateData); + }); + + it('handles ok response with uppercase state', async () => { + let response = { + body: ` + + + dev + my app + 10.0.0 + 12345 + ACTIVE + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + appId: 'dev', + appDevId: '12345', + appTitle: 'my app', + appVersion: '10.0.0', + state: AppState.active, + status: EcpStatus.ok + } as EcpAppStateData); + }); + }); + + describe('error responses', () => { + it('handles failed status with missing error', async () => { + let response = { + body: ` + + FAILED + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + status: EcpStatus.failed, + errorMessage: 'Unknown error' + } as EcpAppStateData); + }); + + it('handles failed status with populated error', async () => { + let response = { + body: ` + + FAILED + App not found + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + status: EcpStatus.failed, + errorMessage: 'App not found' + } as EcpAppStateData); + }); + + it('handles error response without xml', async () => { + let response = { + body: `ECP command not allowed in Limited mode.`, + statusCode: 403 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + status: EcpStatus.failed, + errorMessage: 'ECP command not allowed in Limited mode.' + } as EcpAppStateData); + }); + }); + }); }); diff --git a/src/RokuECP.ts b/src/RokuECP.ts index 3f08202e..ca399842 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -17,9 +17,9 @@ export class RokuECP { if (typeof response.body === 'string') { try { let parsed = await util.parseXml(response.body) as RegistryAsJson; - const status = parsed['plugin-registry'].status[0]; + const status = EcpStatus[parsed['plugin-registry']?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; - if (status === 'OK') { + if (status === EcpStatus.ok) { const registry = parsed['plugin-registry'].registry?.[0]; let sections: EcpRegistryData['sections'] = {}; @@ -39,18 +39,18 @@ export class RokuECP { plugins: registry?.plugins?.[0]?.split(','), sections: sections, spaceAvailable: registry?.['space-available']?.[0], - status: 'OK' + status: status }; } else { return { - status: 'FAILED', + status: status, errorMessage: parsed?.['plugin-registry']?.error?.[0] ?? 'Unknown error' }; } } catch { //if the response is not xml, just return the body as-is return { - status: 'FAILED', + status: EcpStatus.failed, errorMessage: response.body ?? 'Unknown error' }; } @@ -66,27 +66,28 @@ export class RokuECP { if (typeof response.body === 'string') { try { let parsed = await util.parseXml(response.body) as AppStateAsJson; - const status = parsed['app-state'].status[0]; - if (status === 'OK') { + const status = EcpStatus[parsed?.['app-state']?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + if (status === EcpStatus.ok) { const appState = parsed['app-state']; + const state = AppState[appState.state?.[0]?.toLowerCase()] ?? AppState.unknown; return { appId: appState['app-id']?.[0], appDevId: appState['app-dev-id']?.[0], appTitle: appState['app-title']?.[0], appVersion: appState['app-version']?.[0], - state: appState.state?.[0] ?? 'unknown', - status: 'OK' + state: state, + status: status }; } else { return { - status: 'FAILED', + status: status, errorMessage: parsed['app-state']?.error?.[0] ?? 'Unknown error' }; } } catch { //if the response is not xml, just return the body as-is return { - status: 'FAILED', + status: EcpStatus.failed, errorMessage: response.body ?? 'Unknown error' }; } @@ -102,19 +103,19 @@ export class RokuECP { if (typeof response.body === 'string') { try { let parsed = await util.parseXml(response.body) as ExitAppAsJson; - const status = parsed['exit-app'].status[0]; - if (status === 'OK') { - return { status: 'OK' }; + const status = EcpStatus[parsed?.['exit-app']?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + if (status === EcpStatus.ok) { + return { status: status }; } else { return { - status: 'FAILED', + status: status, errorMessage: parsed?.['exit-app']?.error?.[0] ?? 'Unknown error' }; } } catch { //if the response is not xml, just return the body as-is return { - status: 'FAILED', + status: EcpStatus.failed, errorMessage: response.body ?? 'Unknown error' }; } @@ -130,6 +131,11 @@ interface BaseOptions { export type RokuEcpParam = Parameters[0]; +export enum EcpStatus { + ok = 'ok', + failed = 'failed' +} + interface RegistryAsJson { 'plugin-registry': { registry: [{ @@ -159,7 +165,7 @@ export interface EcpRegistryData { sections?: Record>; spaceAvailable?: string; state?: string; - status: 'OK' | 'FAILED'; + status: EcpStatus; errorMessage?: string; } interface AppStateAsJson { @@ -174,13 +180,20 @@ interface AppStateAsJson { }; } +export enum AppState { + active = 'active', + background = 'background', + inactive = 'inactive', + unknown = 'unknown' +} + export interface EcpAppStateData { appId?: string; appTitle?: string; appVersion?: string; appDevId?: string; - state?: 'active' | 'background' | 'inactive' | 'unknown'; - status: 'OK' | 'FAILED'; + state?: AppState; + status: EcpStatus; errorMessage?: string; } @@ -192,7 +205,7 @@ interface ExitAppAsJson { } export interface EcpExitAppData { - status: 'OK' | 'FAILED'; + status: EcpStatus; errorMessage?: string; } diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 3b9066e5..582b19d5 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -58,7 +58,7 @@ import { interfaces, components, events } from 'brighterscript/dist/roku-types'; import { globalCallables } from 'brighterscript/dist/globalCallables'; import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool'; import { populateVariableFromRegistryEcp } from './ecpRegistryUtils'; -import { rokuECP } from '../RokuECP'; +import { AppState, rokuECP } from '../RokuECP'; const diagnosticSource = 'roku-debug'; @@ -2060,9 +2060,9 @@ export class BrightScriptDebugSession extends BaseDebugSession { requestOptions: { timeout: 300 } }); - const state = appStateResult.state?.toLowerCase(); + const state = appStateResult.state; - if (state === 'active' || state === 'background') { + if (state === AppState.active || state === AppState.background) { // Suspends or terminates an app that is running: // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background). // If the app supports Instant Resume and is running in the background or the app does not support Instant Resume and is running, sending this command terminates the app. @@ -2073,7 +2073,7 @@ export class BrightScriptDebugSession extends BaseDebugSession { appId: 'dev', requestOptions: { timeout: 300 } }); - } else if (state === 'inactive') { + } else if (state === AppState.inactive) { return; } } catch (e) { diff --git a/src/debugSession/ecpRegistryUtils.ts b/src/debugSession/ecpRegistryUtils.ts index 037adbe9..f8b8bad4 100644 --- a/src/debugSession/ecpRegistryUtils.ts +++ b/src/debugSession/ecpRegistryUtils.ts @@ -1,14 +1,12 @@ import { VariableType } from '../debugProtocol/events/responses/VariablesResponse'; -import { util } from '../util'; -import { rokuECP } from '../RokuECP'; +import { EcpStatus, rokuECP } from '../RokuECP'; import type { RokuEcpParam } from '../RokuECP'; import type { AugmentedVariable } from './BrightScriptDebugSession'; -import type { Response } from 'request'; export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'getRegistry'>, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { let registryData = await rokuECP.getRegistry(options); - if (registryData.status === 'OK') { + if (registryData.status === EcpStatus.ok) { // Add registry data to variable list if (registryData.devId) { v.childVariables.push({ @@ -126,35 +124,3 @@ export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'get }); } } - -export interface EcpRegistryData { - devId?: string; - plugins?: Array; - sections?: Record>; - spaceAvailable?: string; - status: 'OK' | 'FAILED'; - errorMessage?: string; -} - -interface RegistryAsJson { - 'plugin-registry': { - registry: [{ - 'dev-id': [string]; - plugins: [string]; - sections: [{ - section: [{ - items: [{ - item: [{ - key: [string]; - value: [string]; - }]; - }]; - name: [string]; - } | string]; - }]; - 'space-available': [string]; - }]; - status: [string]; - error?: [string]; - }; -} From d78700790141b2021cae188aab369c5a10f91264 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 12:26:26 -0400 Subject: [PATCH 06/10] Tests for exit app and helper for getting ecp response status --- src/RokuECP.spec.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++ src/RokuECP.ts | 10 ++++-- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index ac445890..31b5a130 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -414,4 +414,89 @@ describe('RokuECP', () => { }); }); }); + + describe('exitApp', () => { + it('calls doRequest with correct route and options', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + appId: 'dev' + }; + + let stub = sinon.stub(rokuECP as any, 'doRequest').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP.exitApp(options); + expect(stub.getCall(0).args).to.eql(['exit-app/dev', options]); + }); + }); + + describe('parseExitApp', () => { + describe('non-error responses', () => { + it('handles ok response', async () => { + let response = { + body: ` + + + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processExitApp'](response as any); + expect(result).to.eql({ + status: EcpStatus.ok + }); + }); + }); + + describe('error responses', () => { + it('handles failed status with missing error', async () => { + let response = { + body: ` + + FAILED + + `, + statusCode: 200 + }; + let result = await rokuECP['processExitApp'](response as any); + expect(result).to.eql({ + status: EcpStatus.failed, + errorMessage: 'Unknown error' + }); + }); + + it('handles failed status with populated error', async () => { + let response = { + body: ` + + FAILED + App not found + + `, + statusCode: 200 + }; + let result = await rokuECP['processExitApp'](response as any); + expect(result).to.eql({ + status: EcpStatus.failed, + errorMessage: 'App not found' + }); + }); + + it('handles error response without xml', async () => { + let response = { + body: `ECP command not allowed in Limited mode.`, + statusCode: 403 + }; + let result = await rokuECP['processExitApp'](response as any); + expect(result).to.eql({ + status: EcpStatus.failed, + errorMessage: 'ECP command not allowed in Limited mode.' + }); + }); + }); + }); }); diff --git a/src/RokuECP.ts b/src/RokuECP.ts index ca399842..3d2e36d9 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -8,6 +8,10 @@ export class RokuECP { return util.httpGet(url, options.requestOptions); } + private getEcpStatus(response: any, rootKey: string): EcpStatus { + return EcpStatus[(response as Record)?.[rootKey]?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + } + public async getRegistry(options: BaseOptions & { appId: string }): Promise { let result = await this.doRequest(`query/registry/${options.appId}`, options); return this.processRegistry(result); @@ -17,7 +21,7 @@ export class RokuECP { if (typeof response.body === 'string') { try { let parsed = await util.parseXml(response.body) as RegistryAsJson; - const status = EcpStatus[parsed['plugin-registry']?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + const status = this.getEcpStatus(parsed, 'plugin-registry'); if (status === EcpStatus.ok) { const registry = parsed['plugin-registry'].registry?.[0]; @@ -66,7 +70,7 @@ export class RokuECP { if (typeof response.body === 'string') { try { let parsed = await util.parseXml(response.body) as AppStateAsJson; - const status = EcpStatus[parsed?.['app-state']?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + const status = this.getEcpStatus(parsed, 'app-state'); if (status === EcpStatus.ok) { const appState = parsed['app-state']; const state = AppState[appState.state?.[0]?.toLowerCase()] ?? AppState.unknown; @@ -103,7 +107,7 @@ export class RokuECP { if (typeof response.body === 'string') { try { let parsed = await util.parseXml(response.body) as ExitAppAsJson; - const status = EcpStatus[parsed?.['exit-app']?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + const status = this.getEcpStatus(parsed, 'exit-app'); if (status === EcpStatus.ok) { return { status: status }; } else { From 83d21ee23596c508766458e6b875f40dae62e267 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 13:53:06 -0400 Subject: [PATCH 07/10] reducing some common flows and more types --- src/RokuECP.ts | 218 ++++++++++++--------------- src/debugSession/ecpRegistryUtils.ts | 4 +- src/util.ts | 2 +- 3 files changed, 96 insertions(+), 128 deletions(-) diff --git a/src/RokuECP.ts b/src/RokuECP.ts index 3d2e36d9..3bbb36f9 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -8,47 +8,21 @@ export class RokuECP { return util.httpGet(url, options.requestOptions); } - private getEcpStatus(response: any, rootKey: string): EcpStatus { - return EcpStatus[(response as Record)?.[rootKey]?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + private getEcpStatus(response: ParsedEcpRoot, rootKey: string): EcpStatus { + return EcpStatus[response?.[rootKey]?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; } - public async getRegistry(options: BaseOptions & { appId: string }): Promise { - let result = await this.doRequest(`query/registry/${options.appId}`, options); - return this.processRegistry(result); - } - - private async processRegistry(response: Response): Promise { + private async parseResponse(response: Response, rootKey: string, callback: (parsed: any, status: EcpStatus) => R): Promise { if (typeof response.body === 'string') { try { - let parsed = await util.parseXml(response.body) as RegistryAsJson; - const status = this.getEcpStatus(parsed, 'plugin-registry'); - + let parsed = await util.parseXml(response.body); + const status = this.getEcpStatus(parsed, rootKey); if (status === EcpStatus.ok) { - const registry = parsed['plugin-registry'].registry?.[0]; - let sections: EcpRegistryData['sections'] = {}; - - for (const section of registry?.sections?.[0]?.section ?? []) { - if (typeof section === 'string') { - continue; - } - let sectionName = section.name[0]; - for (const item of section.items[0].item) { - sections[sectionName] ??= {}; - sections[sectionName][item.key[0]] = item.value[0]; - } - } - - return { - devId: registry?.['dev-id']?.[0], - plugins: registry?.plugins?.[0]?.split(','), - sections: sections, - spaceAvailable: registry?.['space-available']?.[0], - status: status - }; + return callback(parsed?.[rootKey], status); } else { return { status: status, - errorMessage: parsed?.['plugin-registry']?.error?.[0] ?? 'Unknown error' + errorMessage: parsed?.[rootKey]?.error?.[0] ?? 'Unknown error' }; } } catch { @@ -61,41 +35,54 @@ export class RokuECP { } } - public async getAppState(options: BaseOptions & { appId: string }): Promise { - let result = await this.doRequest(`query/app-status/${options.appId}`, options); - return this.processAppState(result); + public async getRegistry(options: BaseOptions & { appId: string }) { + let result = await this.doRequest(`query/registry/${options.appId}`, options); + return this.processRegistry(result); } - private async processAppState(response: Response): Promise { - if (typeof response.body === 'string') { - try { - let parsed = await util.parseXml(response.body) as AppStateAsJson; - const status = this.getEcpStatus(parsed, 'app-state'); - if (status === EcpStatus.ok) { - const appState = parsed['app-state']; - const state = AppState[appState.state?.[0]?.toLowerCase()] ?? AppState.unknown; - return { - appId: appState['app-id']?.[0], - appDevId: appState['app-dev-id']?.[0], - appTitle: appState['app-title']?.[0], - appVersion: appState['app-version']?.[0], - state: state, - status: status - }; - } else { - return { - status: status, - errorMessage: parsed['app-state']?.error?.[0] ?? 'Unknown error' - }; + private async processRegistry(response: Response) { + return this.parseResponse(response, 'plugin-registry', (parsed: RegistryAsJson, status): EcpRegistryData => { + const registry = parsed?.registry?.[0]; + let sections: EcpRegistryData['sections'] = {}; + + for (const section of registry?.sections?.[0]?.section ?? []) { + if (typeof section === 'string') { + continue; + } + let sectionName = section.name[0]; + for (const item of section.items[0].item) { + sections[sectionName] ??= {}; + sections[sectionName][item.key[0]] = item.value[0]; } - } catch { - //if the response is not xml, just return the body as-is - return { - status: EcpStatus.failed, - errorMessage: response.body ?? 'Unknown error' - }; } - } + + return { + devId: registry?.['dev-id']?.[0], + plugins: registry?.plugins?.[0]?.split(','), + sections: sections, + spaceAvailable: registry?.['space-available']?.[0], + status: status + }; + }); + } + + public async getAppState(options: BaseOptions & { appId: string }) { + let result = await this.doRequest(`query/app-status/${options.appId}`, options); + return this.processAppState(result); + } + + private async processAppState(response: Response) { + return this.parseResponse(response, 'app-state', (parsed: AppStateAsJson, status): EcpAppStateData => { + const state = AppState[parsed.state?.[0]?.toLowerCase()] ?? AppState.unknown; + return { + appId: parsed['app-id']?.[0], + appDevId: parsed['app-dev-id']?.[0], + appTitle: parsed['app-title']?.[0], + appVersion: parsed['app-version']?.[0], + state: state, + status: status + }; + }); } public async exitApp(options: BaseOptions & { appId: string }): Promise { @@ -104,84 +91,70 @@ export class RokuECP { } private async processExitApp(response: Response): Promise { - if (typeof response.body === 'string') { - try { - let parsed = await util.parseXml(response.body) as ExitAppAsJson; - const status = this.getEcpStatus(parsed, 'exit-app'); - if (status === EcpStatus.ok) { - return { status: status }; - } else { - return { - status: status, - errorMessage: parsed?.['exit-app']?.error?.[0] ?? 'Unknown error' - }; - } - } catch { - //if the response is not xml, just return the body as-is - return { - status: EcpStatus.failed, - errorMessage: response.body ?? 'Unknown error' - }; - } - } + return this.parseResponse(response, 'exit-app', (parsed: ExitAppAsJson, status): EcpExitAppData => { + return { status: status }; + }); } } +export enum EcpStatus { + ok = 'ok', + failed = 'failed' +} interface BaseOptions { host: string; remotePort?: number; requestOptions?: requestType.CoreOptions; } +interface BaseEcpResponse { + status: EcpStatus; + errorMessage?: string; +} + export type RokuEcpParam = Parameters[0]; -export enum EcpStatus { - ok = 'ok', - failed = 'failed' +export type ParsedEcpRoot = { + [key in T1]: T2; +}; + +interface ParsedEcpBase { + status?: [string]; + error?: [string] ; } -interface RegistryAsJson { - 'plugin-registry': { - registry: [{ - 'dev-id': [string]; - plugins: [string]; - sections: [{ - section: [{ - items: [{ - item: [{ - key: [string]; - value: [string]; - }]; +interface RegistryAsJson extends ParsedEcpBase { + registry: [{ + 'dev-id': [string]; + plugins: [string]; + sections: [{ + section: [{ + items: [{ + item: [{ + key: [string]; + value: [string]; }]; - name: [string]; - } | string]; - }]; - 'space-available': [string]; + }]; + name: [string]; + } | string]; }]; - status: [string]; - error?: [string]; - }; + 'space-available': [string]; + }]; } -export interface EcpRegistryData { +export interface EcpRegistryData extends BaseEcpResponse { devId?: string; plugins?: Array; sections?: Record>; spaceAvailable?: string; state?: string; - status: EcpStatus; - errorMessage?: string; } -interface AppStateAsJson { - 'app-state': { - 'app-id': [string]; - 'app-title': [string]; - 'app-version': [string]; - 'app-dev-id': [string]; - state: ['active' | 'background' | 'inactive']; - status: [string]; - error?: [string]; - }; +interface AppStateAsJson extends ParsedEcpBase { + 'app-id': [string]; + 'app-title': [string]; + 'app-version': [string]; + 'app-dev-id': [string]; + state: ['active' | 'background' | 'inactive']; } export enum AppState { @@ -201,12 +174,7 @@ export interface EcpAppStateData { errorMessage?: string; } -interface ExitAppAsJson { - 'exit-app': { - status: [string]; - error?: [string]; - }; -} +type ExitAppAsJson = ParsedEcpBase; export interface EcpExitAppData { status: EcpStatus; diff --git a/src/debugSession/ecpRegistryUtils.ts b/src/debugSession/ecpRegistryUtils.ts index f8b8bad4..47d69612 100644 --- a/src/debugSession/ecpRegistryUtils.ts +++ b/src/debugSession/ecpRegistryUtils.ts @@ -4,9 +4,9 @@ import type { RokuEcpParam } from '../RokuECP'; import type { AugmentedVariable } from './BrightScriptDebugSession'; export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'getRegistry'>, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { - let registryData = await rokuECP.getRegistry(options); + let result = await rokuECP.getRegistry(options); - if (registryData.status === EcpStatus.ok) { + if (result.status === EcpStatus.ok) { // Add registry data to variable list if (registryData.devId) { v.childVariables.push({ diff --git a/src/util.ts b/src/util.ts index 4743923b..51e0ff6d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -514,7 +514,7 @@ class Util { /** * Parse an xml file and get back a javascript object containing its results */ - public parseXml(text: string) { + public parseXml(text: string): Promise { return new Promise((resolve, reject) => { xml2js.parseString(text, (err, data) => { if (err) { From 50778d6f462d73f03c3339b5587ef912a779a46d Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 14:23:04 -0400 Subject: [PATCH 08/10] Moved to thrown errors --- src/RokuECP.spec.ts | 71 +++++++--------------------- src/RokuECP.ts | 26 +++++----- src/debugSession/ecpRegistryUtils.ts | 9 ++-- 3 files changed, 33 insertions(+), 73 deletions(-) diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index 31b5a130..fba71d06 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -4,11 +4,12 @@ import { describe } from 'mocha'; import type { EcpAppStateData, EcpRegistryData } from './RokuECP'; import { AppState, EcpStatus, rokuECP } from './RokuECP'; import { util } from './util'; +import { expectThrowsAsync } from './testHelpers.spec'; const sinon = createSandbox(); -describe('RokuECP', () => { +describe.only('RokuECP', () => { beforeEach(() => { sinon.restore(); @@ -90,6 +91,7 @@ describe('RokuECP', () => { body: '', statusCode: 200 }); + sinon.stub(rokuECP as any, 'processRegistry').resolves({}); await rokuECP.getRegistry(options); expect(stub.getCall(0).args).to.eql(['query/registry/dev', options]); @@ -214,11 +216,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processRegistry'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'Plugin dev not found' - } as EcpRegistryData); + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'Plugin dev not found'); }); it('handles device not keyed', async () => { @@ -231,11 +229,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processRegistry'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'Device not keyed' - } as EcpRegistryData); + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'Device not keyed'); }); it('handles failed status with missing error', async () => { @@ -247,11 +241,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processRegistry'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'Unknown error' - } as EcpRegistryData); + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'Unknown error'); }); it('handles error response without xml', async () => { @@ -259,11 +249,7 @@ describe('RokuECP', () => { body: `ECP command not allowed in Limited mode.`, statusCode: 403 }; - let result = await rokuECP['processRegistry'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'ECP command not allowed in Limited mode.' - } as EcpRegistryData); + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'ECP command not allowed in Limited mode.'); }); }); }); @@ -281,12 +267,14 @@ describe('RokuECP', () => { statusCode: 200 }); + sinon.stub(rokuECP as any, 'processAppState').resolves({}); + await rokuECP.getAppState(options); expect(stub.getCall(0).args).to.eql(['query/app-status/dev', options]); }); }); - describe('parseAppState', () => { + describe('processAppState', () => { describe('non-error responses', () => { it('handles ok response', async () => { let response = { @@ -377,11 +365,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processAppState'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'Unknown error' - } as EcpAppStateData); + await expectThrowsAsync(() => rokuECP['processAppState'](response as any), 'Unknown error'); }); it('handles failed status with populated error', async () => { @@ -394,11 +378,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processAppState'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'App not found' - } as EcpAppStateData); + await expectThrowsAsync(() => rokuECP['processAppState'](response as any), 'App not found'); }); it('handles error response without xml', async () => { @@ -406,11 +386,7 @@ describe('RokuECP', () => { body: `ECP command not allowed in Limited mode.`, statusCode: 403 }; - let result = await rokuECP['processAppState'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'ECP command not allowed in Limited mode.' - } as EcpAppStateData); + await expectThrowsAsync(() => rokuECP['processAppState'](response as any), 'ECP command not allowed in Limited mode.'); }); }); }); @@ -427,13 +403,14 @@ describe('RokuECP', () => { body: '', statusCode: 200 }); + sinon.stub(rokuECP as any, 'processExitApp').resolves({}); await rokuECP.exitApp(options); expect(stub.getCall(0).args).to.eql(['exit-app/dev', options]); }); }); - describe('parseExitApp', () => { + describe('processExitApp', () => { describe('non-error responses', () => { it('handles ok response', async () => { let response = { @@ -462,11 +439,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processExitApp'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'Unknown error' - }); + await expectThrowsAsync(() => rokuECP['processExitApp'](response as any), 'Unknown error'); }); it('handles failed status with populated error', async () => { @@ -479,11 +452,7 @@ describe('RokuECP', () => { `, statusCode: 200 }; - let result = await rokuECP['processExitApp'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'App not found' - }); + await expectThrowsAsync(() => rokuECP['processExitApp'](response as any), 'App not found'); }); it('handles error response without xml', async () => { @@ -491,11 +460,7 @@ describe('RokuECP', () => { body: `ECP command not allowed in Limited mode.`, statusCode: 403 }; - let result = await rokuECP['processExitApp'](response as any); - expect(result).to.eql({ - status: EcpStatus.failed, - errorMessage: 'ECP command not allowed in Limited mode.' - }); + await expectThrowsAsync(() => rokuECP['processExitApp'](response as any), 'ECP command not allowed in Limited mode.'); }); }); }); diff --git a/src/RokuECP.ts b/src/RokuECP.ts index 3bbb36f9..5d040881 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -12,25 +12,21 @@ export class RokuECP { return EcpStatus[response?.[rootKey]?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; } - private async parseResponse(response: Response, rootKey: string, callback: (parsed: any, status: EcpStatus) => R): Promise { + private async parseResponse(response: Response, rootKey: string, callback: (parsed: any, status: EcpStatus) => R): Promise { if (typeof response.body === 'string') { + let parsed: ParsedEcpRoot; try { - let parsed = await util.parseXml(response.body); - const status = this.getEcpStatus(parsed, rootKey); - if (status === EcpStatus.ok) { - return callback(parsed?.[rootKey], status); - } else { - return { - status: status, - errorMessage: parsed?.[rootKey]?.error?.[0] ?? 'Unknown error' - }; - } + parsed = await util.parseXml(response.body); } catch { //if the response is not xml, just return the body as-is - return { - status: EcpStatus.failed, - errorMessage: response.body ?? 'Unknown error' - }; + throw new Error(response.body ?? 'Unknown error'); + } + + const status = this.getEcpStatus(parsed, rootKey); + if (status === EcpStatus.ok) { + return callback(parsed?.[rootKey], status); + } else { + throw new Error(parsed?.[rootKey]?.error?.[0] ?? 'Unknown error'); } } } diff --git a/src/debugSession/ecpRegistryUtils.ts b/src/debugSession/ecpRegistryUtils.ts index 47d69612..e3f1f9a8 100644 --- a/src/debugSession/ecpRegistryUtils.ts +++ b/src/debugSession/ecpRegistryUtils.ts @@ -4,9 +4,8 @@ import type { RokuEcpParam } from '../RokuECP'; import type { AugmentedVariable } from './BrightScriptDebugSession'; export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'getRegistry'>, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { - let result = await rokuECP.getRegistry(options); - - if (result.status === EcpStatus.ok) { + try { + let registryData = await rokuECP.getRegistry(options); // Add registry data to variable list if (registryData.devId) { v.childVariables.push({ @@ -114,10 +113,10 @@ export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'get return sectionItemVariable; })); } - } else { + } catch (e) { v.childVariables.push({ name: 'error', - value: `❌ Error: ${registryData.errorMessage ?? 'Unknown error'}`, + value: `❌ Error: ${e.message ?? 'Unknown error'}`, type: VariableType.String, variablesReference: 0, childVariables: [] From 2879a315e2a3b4e982acf1c5f4ef7b9e640e2400 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 14:23:22 -0400 Subject: [PATCH 09/10] Removed .only --- src/RokuECP.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index fba71d06..44ddee88 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -9,7 +9,7 @@ import { expectThrowsAsync } from './testHelpers.spec'; const sinon = createSandbox(); -describe.only('RokuECP', () => { +describe('RokuECP', () => { beforeEach(() => { sinon.restore(); From 57aa4f75da4be07e0290f91d8af6aa2a04a3d0f9 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Fri, 14 Feb 2025 14:52:26 -0400 Subject: [PATCH 10/10] Fixed some bugs and updated tests --- src/RokuECP.spec.ts | 30 ++++++++++++++++++-- src/RokuECP.ts | 12 +++++--- src/debugSession/BrightScriptDebugSession.ts | 2 +- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index 44ddee88..c285c3d5 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -77,6 +77,32 @@ describe('RokuECP', () => { await rokuECP['doRequest']('/query/my-route', options); expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); }); + + it('supports get and post methods', async () => { + let options = { + host: '1.1.1.1' + }; + + const getStub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + const postStub = sinon.stub(util as any, 'httpPost').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options, 'get'); + expect(getStub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); + expect(getStub.callCount).to.eql(1); + expect(postStub.callCount).to.eql(0); + + await rokuECP['doRequest']('/query/my-route', options, 'post'); + expect(postStub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); + expect(getStub.callCount).to.eql(1); + expect(postStub.callCount).to.eql(1); + + }); }); describe('getRegistry', () => { @@ -270,7 +296,7 @@ describe('RokuECP', () => { sinon.stub(rokuECP as any, 'processAppState').resolves({}); await rokuECP.getAppState(options); - expect(stub.getCall(0).args).to.eql(['query/app-status/dev', options]); + expect(stub.getCall(0).args).to.eql(['query/app-state/dev', options]); }); }); @@ -406,7 +432,7 @@ describe('RokuECP', () => { sinon.stub(rokuECP as any, 'processExitApp').resolves({}); await rokuECP.exitApp(options); - expect(stub.getCall(0).args).to.eql(['exit-app/dev', options]); + expect(stub.getCall(0).args).to.eql(['exit-app/dev', options, 'post']); }); }); diff --git a/src/RokuECP.ts b/src/RokuECP.ts index 5d040881..a421c3da 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -3,9 +3,13 @@ import type * as requestType from 'request'; import type { Response } from 'request'; export class RokuECP { - private async doRequest(route: string, options: BaseOptions): Promise { + private async doRequest(route: string, options: BaseOptions, method: 'post' | 'get' = 'get'): Promise { const url = `http://${options.host}:${options.remotePort ?? 8060}/${route.replace(/^\//, '')}`; - return util.httpGet(url, options.requestOptions); + if (method === 'post') { + return util.httpPost(url, options.requestOptions); + } else { + return util.httpGet(url, options.requestOptions); + } } private getEcpStatus(response: ParsedEcpRoot, rootKey: string): EcpStatus { @@ -63,7 +67,7 @@ export class RokuECP { } public async getAppState(options: BaseOptions & { appId: string }) { - let result = await this.doRequest(`query/app-status/${options.appId}`, options); + let result = await this.doRequest(`query/app-state/${options.appId}`, options); return this.processAppState(result); } @@ -82,7 +86,7 @@ export class RokuECP { } public async exitApp(options: BaseOptions & { appId: string }): Promise { - let result = await this.doRequest(`exit-app/${options.appId}`, options); + let result = await this.doRequest(`exit-app/${options.appId}`, options, 'post'); return this.processExitApp(result); } diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 32585ae0..49215c16 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -2052,7 +2052,7 @@ export class BrightScriptDebugSession extends BaseDebugSession { await this.launchRequest(response, args.arguments as LaunchConfiguration); } - private exitAppTimeout: 5000; + private exitAppTimeout = 5000; private async ensureAppIsInactive() { const startTime = Date.now();