diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d467ff..044f0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [3.11.0](https://github.com/rokucommunity/roku-deploy/compare/v3.10.5...v3.11.0) - 2023-11-28 +### Changed + - Add public function `normalizeDeviceInfoFieldValue` to normalize device-info field values ([#129](https://github.com/rokucommunity/roku-deploy/pull/129)) + + + +## [3.10.5](https://github.com/rokucommunity/roku-deploy/compare/v3.10.4...v3.10.5) - 2023-11-14 +### Changed + - better device-info docs ([#128](https://github.com/rokucommunity/roku-deploy/pull/128)) + - Better deploy error detection ([#127](https://github.com/rokucommunity/roku-deploy/pull/127)) + + + +## [3.10.4](https://github.com/rokucommunity/roku-deploy/compare/v3.10.3...v3.10.4) - 2023-11-03 +### Changed + - Enhance getDeviceInfo() method to support camelCase and convert bool|number strings to their primitive types ([#120](https://github.com/rokucommunity/roku-deploy/pull/120)) + + + ## [3.10.3](https://github.com/rokucommunity/roku-deploy/compare/v3.10.2...3.10.3) - 2023-07-22 ### Changed - Bump word-wrap from 1.2.3 to 1.2.4 ([#117](https://github.com/rokucommunity/roku-deploy/pull/117)) diff --git a/package-lock.json b/package-lock.json index eb62b46..15c7eec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roku-deploy", - "version": "3.10.3", + "version": "3.11.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "roku-deploy", - "version": "3.10.3", + "version": "3.11.0", "license": "MIT", "dependencies": { "chalk": "^2.4.2", @@ -17,6 +17,7 @@ "is-glob": "^4.0.3", "jsonc-parser": "^2.3.0", "jszip": "^3.6.0", + "lodash": "^4.17.21", "micromatch": "^4.0.4", "moment": "^2.29.1", "parse-ms": "^2.1.0", @@ -31,6 +32,7 @@ "@types/chai": "^4.2.22", "@types/fs-extra": "^5.0.1", "@types/is-glob": "^4.0.2", + "@types/lodash": "^4.14.200", "@types/micromatch": "^4.0.2", "@types/mocha": "^9.0.0", "@types/node": "^16.11.3", @@ -733,6 +735,12 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.200", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz", + "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==", + "dev": true + }, "node_modules/@types/micromatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.2.tgz", @@ -2935,6 +2943,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -5427,6 +5440,12 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "@types/lodash": { + "version": "4.14.200", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz", + "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==", + "dev": true + }, "@types/micromatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.2.tgz", @@ -7070,6 +7089,11 @@ "p-locate": "^5.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", diff --git a/package.json b/package.json index 55a2134..9cdbb05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roku-deploy", - "version": "3.10.3", + "version": "3.11.0", "description": "Package and publish a Roku application using Node.js", "main": "dist/index.js", "scripts": { @@ -25,6 +25,7 @@ "is-glob": "^4.0.3", "jsonc-parser": "^2.3.0", "jszip": "^3.6.0", + "lodash": "^4.17.21", "micromatch": "^4.0.4", "moment": "^2.29.1", "parse-ms": "^2.1.0", @@ -36,6 +37,7 @@ "@types/chai": "^4.2.22", "@types/fs-extra": "^5.0.1", "@types/is-glob": "^4.0.2", + "@types/lodash": "^4.14.200", "@types/micromatch": "^4.0.2", "@types/mocha": "^9.0.0", "@types/node": "^16.11.3", diff --git a/src/DeviceInfo.ts b/src/DeviceInfo.ts new file mode 100644 index 0000000..cc18f8e --- /dev/null +++ b/src/DeviceInfo.ts @@ -0,0 +1,149 @@ +//there are 2 copies of this interface in here. If you add a new field, be sure to add it to both + +export interface DeviceInfo { + udn?: string; + serialNumber?: string; + deviceId?: string; + advertisingId?: string; + vendorName?: string; + modelName?: string; + modelNumber?: string; + modelRegion?: string; + isTv?: boolean; + isStick?: boolean; + mobileHasLiveTv?: boolean; + uiResolution?: string; + supportsEthernet?: boolean; + wifiMac?: string; + wifiDriver?: string; + hasWifiExtender?: boolean; + hasWifi5GSupport?: boolean; + canUseWifiExtender?: boolean; + ethernetMac?: string; + networkType?: string; + networkName?: string; + friendlyDeviceName?: string; + friendlyModelName?: string; + defaultDeviceName?: string; + userDeviceName?: string; + userDeviceLocation?: string; + buildNumber?: string; + softwareVersion?: string; + softwareBuild?: number; + secureDevice?: boolean; + language?: string; + country?: string; + locale?: string; + timeZoneAuto?: boolean; + timeZone?: string; + timeZoneName?: string; + timeZoneTz?: string; + timeZoneOffset?: number; + clockFormat?: string; + uptime?: number; + powerMode?: string; + supportsSuspend?: boolean; + supportsFindRemote?: boolean; + findRemoteIsPossible?: boolean; + supportsAudioGuide?: boolean; + supportsRva?: boolean; + hasHandsFreeVoiceRemote?: boolean; + developerEnabled?: boolean; + keyedDeveloperId?: string; + searchEnabled?: boolean; + searchChannelsEnabled?: boolean; + voiceSearchEnabled?: boolean; + notificationsEnabled?: boolean; + notificationsFirstUse?: boolean; + supportsPrivateListening?: boolean; + headphonesConnected?: boolean; + supportsAudioSettings?: boolean; + supportsEcsTextedit?: boolean; + supportsEcsMicrophone?: boolean; + supportsWakeOnWlan?: boolean; + supportsAirplay?: boolean; + hasPlayOnRoku?: boolean; + hasMobileScreensaver?: boolean; + supportUrl?: string; + grandcentralVersion?: string; + trcVersion?: number; + trcChannelVersion?: string; + davinciVersion?: string; + avSyncCalibrationEnabled?: number; + brightscriptDebuggerVersion?: string; +} + +export interface DeviceInfoRaw { + 'udn'?: string; + 'serialNumber'?: string; + 'deviceId'?: string; + 'advertising-id'?: string; + 'vendor-name'?: string; + 'model-name'?: string; + 'model-number'?: string; + 'model-region'?: string; + 'is-tv'?: string; + 'is-stick'?: string; + 'mobile-has-live-tv'?: string; + 'ui-resolution'?: string; + 'supports-ethernet'?: string; + 'wifi-mac'?: string; + 'wifi-driver'?: string; + 'has-wifi-extender'?: string; + 'has-wifi-5G-support'?: string; + 'can-use-wifi-extender'?: string; + 'ethernet-mac'?: string; + 'network-type'?: string; + 'network-name'?: string; + 'friendly-device-name'?: string; + 'friendly-model-name'?: string; + 'default-device-name'?: string; + 'user-device-name'?: string; + 'user-device-location'?: string; + 'build-number'?: string; + 'software-version'?: string; + 'software-build'?: string; + 'secure-device'?: string; + 'language'?: string; + 'country'?: string; + 'locale'?: string; + 'time-zone-auto'?: string; + 'time-zone'?: string; + 'time-zone-name'?: string; + 'time-zone-tz'?: string; + 'time-zone-offset'?: string; + 'clock-format'?: string; + 'uptime'?: string; + 'power-mode'?: string; + 'supports-suspend'?: string; + 'supports-find-remote'?: string; + 'find-remote-is-possible'?: string; + 'supports-audio-guide'?: string; + 'supports-rva'?: string; + 'has-hands-free-voice-remote'?: string; + 'developer-enabled'?: string; + 'keyed-developer-id'?: string; + 'search-enabled'?: string; + 'search-channels-enabled'?: string; + 'voice-search-enabled'?: string; + 'notifications-enabled'?: string; + 'notifications-first-use'?: string; + 'supports-private-listening'?: string; + 'headphones-connected'?: string; + 'supports-audio-settings'?: string; + 'supports-ecs-textedit'?: string; + 'supports-ecs-microphone'?: string; + 'supports-wake-on-wlan'?: string; + 'supports-airplay'?: string; + 'has-play-on-roku'?: string; + 'has-mobile-screensaver'?: string; + 'support-url'?: string; + 'grandcentral-version'?: string; + 'trc-version'?: string; + 'trc-channel-version'?: string; + 'davinci-version'?: string; + 'av-sync-calibration-enabled'?: string; + 'brightscript-debugger-version'?: string; + // catchall index lookup for keys we weren't aware of + [key: string]: any; +} diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index 55a88c2..8a591ee 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -168,7 +168,7 @@ describe('index', () => { return {} as any; }); - let results = await rokuDeploy['doGetRequest']({}); + let results = await rokuDeploy['doGetRequest']({} as any); expect(results.body).to.equal(body); }); @@ -180,7 +180,7 @@ describe('index', () => { }); try { - await rokuDeploy['doGetRequest']({}); + await rokuDeploy['doGetRequest']({} as any); } catch (e) { expect(e).to.equal(error); return; @@ -237,93 +237,321 @@ describe('index', () => { let results = rokuDeploy['getRokuMessagesFromResponseBody'](body); expect(results).to.eql({ - errors: ['Failure: Form Error: "archive" Field Not Found', 'Failure: Form Error: "archive" Field Not Found'], + errors: ['Failure: Form Error: "archive" Field Not Found'], infos: ['Some random info message'], successes: ['Screenshot ok'] }); }); + + it('pull many messages from the response body including json messages', () => { + let body = getFakeResponseBody(` + Shell.create('Roku.Message').trigger('Set message type', 'success').trigger('Set message content', 'Screenshot ok').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'info').trigger('Set message content', 'Some random info message').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Failure: Form Error: "archive" Field Not Found').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Failure: Form Error: "archive" Field Not Found').trigger('Render', node); + + var params = JSON.parse('{"messages":[{"text":"Application Received: 2500809 bytes stored.","text_type":"text","type":"success"},{"text":"Install Failure: Error parsing XML component SupportedFeaturesView.xml","text_type":"text","type":"error"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Screenshot ok","text_type":"text","type":"success"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + `); + + let results = rokuDeploy['getRokuMessagesFromResponseBody'](body); + expect(results).to.eql({ + errors: ['Failure: Form Error: "archive" Field Not Found', 'Install Failure: Error parsing XML component SupportedFeaturesView.xml'], + infos: ['Some random info message'], + successes: ['Screenshot ok', 'Application Received: 2500809 bytes stored.'] + }); + }); + + it('pull many messages from the response body including json messages and dedupe them', () => { + let bodyOne = getFakeResponseBody(` + Shell.create('Roku.Message').trigger('Set message type', 'success').trigger('Set message content', 'Screenshot ok').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'success').trigger('Set message content', 'Screenshot ok').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'info').trigger('Set message content', 'Some random info message').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'info').trigger('Set message content', 'Some random info message').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Failure: Form Error: "archive" Field Not Found').trigger('Render', node); + Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Failure: Form Error: "archive" Field Not Found').trigger('Render', node); + + var params = JSON.parse('{"messages":[{"text":"Application Received: 2500809 bytes stored.","text_type":"text","type":"success"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Application Received: 2500809 bytes stored.","text_type":"text","type":"success"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Install Failure: Error parsing XML component SupportedFeaturesView.xml","text_type":"text","type":"error"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Install Failure: Error parsing XML component SupportedFeaturesView.xml","text_type":"text","type":"error"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Some random info message","text_type":"text","type":"info"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Some random info message","text_type":"text","type":"info"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"wont be added","text_type":"text","type":"unknown"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"doesn't look like a roku message","text_type":"text"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"doesn't look like a roku message","type":"info"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"type":"info"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('[]'); + `); + + let resultsOne = rokuDeploy['getRokuMessagesFromResponseBody'](bodyOne); + expect(resultsOne).to.eql({ + errors: ['Failure: Form Error: "archive" Field Not Found', 'Install Failure: Error parsing XML component SupportedFeaturesView.xml'], + infos: ['Some random info message'], + successes: ['Screenshot ok', 'Application Received: 2500809 bytes stored.'] + }); + + let bodyTwo = getFakeResponseBody(` + var params = JSON.parse('{"messages":[{"text":"Application Received: 2500809 bytes stored.","text_type":"text","type":"success"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Application Received: 2500809 bytes stored.","text_type":"text","type":"success"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Install Failure: Error parsing XML component SupportedFeaturesView.xml","text_type":"text","type":"error"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Install Failure: Error parsing XML component SupportedFeaturesView.xml","text_type":"text","type":"error"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Some random info message","text_type":"text","type":"info"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"messages":[{"text":"Some random info message","text_type":"text","type":"info"}],"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + var params = JSON.parse('{"metadata":{"dev_id":"123456789","dev_key":true,"voice_sdk":false},"packages":[]}'); + `); + + let resultsTwo = rokuDeploy['getRokuMessagesFromResponseBody'](bodyTwo); + expect(resultsTwo).to.eql({ + errors: ['Install Failure: Error parsing XML component SupportedFeaturesView.xml'], + infos: ['Some random info message'], + successes: ['Application Received: 2500809 bytes stored.'] + }); + }); }); describe('getDeviceInfo', () => { + const body = ` + 29380007-0800-1025-80a4-d83154332d7e + 123 + 456 + 2cv488ca-d6ec-5222-9304-1925e72d0122 + Roku + Roku Ultra + 4660X + US + false + false + true + d8:31:34:33:6d:6e + realtek + false + true + true + e8:31:34:36:2d:2e + ethernet + Brian's Roku Ultra + Roku Ultra + Roku Ultra - YB0072009656 + Brian's Roku Ultra + Hot Tub + 469.30E04170A + 9.3.0 + 4170 + true + en + US + en_US + true + US/Eastern + United States/Eastern + America/New_York + -240 + 12-hour + 19799 + PowerOn + false + true + true + true + true + true + 789 + true + true + true + true + false + true + false + true + true + false + true + true + roku.com/support + 3.1.39 + 3.0 + 2.9.42 + 3.0 + 2.8.20 + 3.2.0 + false + true + Plumb-5G + true + false + 1080p + `; + it('should return device info matching what was returned by ECP', async () => { - const expectedSerialNumber = 'expectedSerialNumber'; - const expectedDeviceId = 'expectedDeviceId'; - const expectedDeveloperId = 'expectedDeveloperId'; - const body = ` - 29380007-0800-1025-80a4-d83154332d7e - ${expectedSerialNumber} - ${expectedDeviceId} - 2cv488ca-d6ec-5222-9304-1925e72d0122 - Roku - Roku Ultra - 4660X - US - false - false - true - d8:31:34:33:6d:6e - realtek - false - true - true - e8:31:34:36:2d:2e - ethernet - Brian's Roku Ultra - Roku Ultra - Roku Ultra - YB0072009656 - Brian's Roku Ultra - Hot Tub - 469.30E04170A - 9.3.0 - 4170 - true - en - US - en_US - true - US/Eastern - United States/Eastern - America/New_York - -240 - 12-hour - 19799 - PowerOn - false - true - true - true - true - true - ${expectedDeveloperId} - true - true - true - true - false - true - false - true - true - false - true - true - roku.com/support - 3.1.39 - 3.0 - 2.9.42 - 2.8.20 - `; mockDoGetRequest(body); - const deviceInfo = await rokuDeploy.getDeviceInfo(options); - expect(deviceInfo['serial-number']).to.equal(expectedSerialNumber); - expect(deviceInfo['device-id']).to.equal(expectedDeviceId); - expect(deviceInfo['keyed-developer-id']).to.equal(expectedDeveloperId); + const deviceInfo = await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + expect(deviceInfo['serial-number']).to.equal('123'); + expect(deviceInfo['device-id']).to.equal('456'); + expect(deviceInfo['keyed-developer-id']).to.equal('789'); + }); + + it('should default to port 8060 if not provided', async () => { + const stub = mockDoGetRequest(body); + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + expect(stub.getCall(0).args[0].url).to.eql('http://1.1.1.1:8060/query/device-info'); + }); + + it('should use given port if provided', async () => { + const stub = mockDoGetRequest(body); + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1', remotePort: 9999 }); + expect(stub.getCall(0).args[0].url).to.eql('http://1.1.1.1:9999/query/device-info'); + }); + + + it('does not crash when sanitizing fields that are not defined', async () => { + mockDoGetRequest(` + + 29380007-0800-1025-80a4-d83154332d7e + + `); + const result = await rokuDeploy.getDeviceInfo({ host: '192.168.1.10', remotePort: 8060, enhance: true }); + expect(result.isStick).not.to.exist; + }); + + it('returns kebab-case by default', async () => { + mockDoGetRequest(` + + true + + `); + const result = await rokuDeploy.getDeviceInfo({ host: '192.168.1.10' }); + expect(result['has-mobile-screensaver']).to.eql('true'); + }); + + it('should sanitize additional data when the host+param+format signature is triggered', async () => { + mockDoGetRequest(body); + const result = await rokuDeploy.getDeviceInfo({ host: '192.168.1.10', remotePort: 8060, enhance: true }); + expect(result).to.include({ + // make sure the number fields are turned into numbers + softwareBuild: 4170, + uptime: 19799, + trcVersion: 3.0, + timeZoneOffset: -240, + + // string booleans should be turned into booleans + isTv: false, + isStick: false, + supportsEthernet: true, + hasWifiExtender: false, + hasWifi5GSupport: true, + secureDevice: true, + timeZoneAuto: true, + supportsSuspend: false, + supportsFindRemote: true, + findRemoteIsPossible: true, + supportsAudioGuide: true, + supportsRva: true, + developerEnabled: true, + searchEnabled: true, + searchChannelsEnabled: true, + voiceSearchEnabled: true, + notificationsEnabled: true, + notificationsFirstUse: false, + supportsPrivateListening: true, + headphonesConnected: false, + supportsEcsTextedit: true, + supportsEcsMicrophone: true, + supportsWakeOnWlan: false, + hasPlayOnRoku: true, + hasMobileScreensaver: true + }); + }); + + it('converts keys to camel case when enabled', async () => { + mockDoGetRequest(body); + const result = await rokuDeploy.getDeviceInfo({ host: '192.168.1.10', remotePort: 8060, enhance: true }); + const props = [ + 'udn', + 'serialNumber', + 'deviceId', + 'advertisingId', + 'vendorName', + 'modelName', + 'modelNumber', + 'modelRegion', + 'isTv', + 'isStick', + 'mobileHasLiveTv', + 'uiResolution', + 'supportsEthernet', + 'wifiMac', + 'wifiDriver', + 'hasWifiExtender', + 'hasWifi5GSupport', + 'canUseWifiExtender', + 'ethernetMac', + 'networkType', + 'networkName', + 'friendlyDeviceName', + 'friendlyModelName', + 'defaultDeviceName', + 'userDeviceName', + 'userDeviceLocation', + 'buildNumber', + 'softwareVersion', + 'softwareBuild', + 'secureDevice', + 'language', + 'country', + 'locale', + 'timeZoneAuto', + 'timeZone', + 'timeZoneName', + 'timeZoneTz', + 'timeZoneOffset', + 'clockFormat', + 'uptime', + 'powerMode', + 'supportsSuspend', + 'supportsFindRemote', + 'findRemoteIsPossible', + 'supportsAudioGuide', + 'supportsRva', + 'hasHandsFreeVoiceRemote', + 'developerEnabled', + 'keyedDeveloperId', + 'searchEnabled', + 'searchChannelsEnabled', + 'voiceSearchEnabled', + 'notificationsEnabled', + 'notificationsFirstUse', + 'supportsPrivateListening', + 'headphonesConnected', + 'supportsAudioSettings', + 'supportsEcsTextedit', + 'supportsEcsMicrophone', + 'supportsWakeOnWlan', + 'supportsAirplay', + 'hasPlayOnRoku', + 'hasMobileScreensaver', + 'supportUrl', + 'grandcentralVersion', + 'trcVersion', + 'trcChannelVersion', + 'davinciVersion', + 'avSyncCalibrationEnabled', + 'brightscriptDebuggerVersion' + ]; + expect( + Object.keys(result).sort() + ).to.eql( + props.sort() + ); }); it('should throw our error on failure', async () => { mockDoGetRequest(); try { - await rokuDeploy.getDeviceInfo(options); + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); } catch (e) { expect(e).to.be.instanceof(errors.UnparsableDeviceResponseError); return; @@ -332,6 +560,29 @@ describe('index', () => { }); }); + describe('normalizeDeviceInfoFieldValue', () => { + it('converts normal values', () => { + expect(rokuDeploy.normalizeDeviceInfoFieldValue('true')).to.eql(true); + expect(rokuDeploy.normalizeDeviceInfoFieldValue('false')).to.eql(false); + expect(rokuDeploy.normalizeDeviceInfoFieldValue('1')).to.eql(1); + expect(rokuDeploy.normalizeDeviceInfoFieldValue('1.2')).to.eql(1.2); + //it'll trim whitespace too + expect(rokuDeploy.normalizeDeviceInfoFieldValue(' 1.2')).to.eql(1.2); + expect(rokuDeploy.normalizeDeviceInfoFieldValue(' 1.2 ')).to.eql(1.2); + }); + + it('leaves invalid numbers as strings', () => { + expect(rokuDeploy.normalizeDeviceInfoFieldValue('v1.2.3')).to.eql('v1.2.3'); + expect(rokuDeploy.normalizeDeviceInfoFieldValue('1.2.3-alpha.1')).to.eql('1.2.3-alpha.1'); + expect(rokuDeploy.normalizeDeviceInfoFieldValue('123Four')).to.eql('123Four'); + }); + + it('decodes HTML entities', () => { + expect(rokuDeploy.normalizeDeviceInfoFieldValue('3&4')).to.eql('3&4'); + expect(rokuDeploy.normalizeDeviceInfoFieldValue('3&4')).to.eql('3&4'); + }); + }); + describe('getDevId', () => { it('should return the current Dev ID if successful', async () => { @@ -373,7 +624,7 @@ describe('index', () => { }); const copyPaths = [] as Array<{ src: string; dest: string }>; sinon.stub(rokuDeploy.fsExtra as any, 'copy').callsFake((src, dest) => { - copyPaths.push({ src: src, dest: dest }); + copyPaths.push({ src: src as string, dest: dest as string }); return Promise.resolve(); }); @@ -3244,7 +3495,7 @@ describe('index', () => { }); function mockDoGetRequest(body = '', statusCode = 200) { - sinon.stub(rokuDeploy as any, 'doGetRequest').callsFake((params) => { + return sinon.stub(rokuDeploy as any, 'doGetRequest').callsFake((params) => { let results = { response: { statusCode: statusCode }, body: body }; rokuDeploy['checkRequest'](results); return Promise.resolve(results); diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index 8cd0c5d..9d6e93a 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -1,6 +1,8 @@ import * as path from 'path'; import * as _fsExtra from 'fs-extra'; -import * as request from 'postman-request'; +import * as r from 'postman-request'; +import type * as requestType from 'request'; +const request = r as typeof requestType; import * as JSZip from 'jszip'; import * as dateformat from 'dateformat'; import * as errors from './Errors'; @@ -14,6 +16,8 @@ import type { RokuDeployOptions, FileEntry } from './RokuDeployOptions'; import { Logger, LogLevel } from './Logger'; import * as tempDir from 'temp-dir'; import * as dayjs from 'dayjs'; +import * as lodash from 'lodash'; +import type { DeviceInfo, DeviceInfoRaw } from './DeviceInfo'; export class RokuDeploy { @@ -370,7 +374,7 @@ export class RokuDeploy { })); } - private generateBaseRequestOptions(requestPath: string, options: RokuDeployOptions, formData = {} as T): request.OptionsWithUrl { + private generateBaseRequestOptions(requestPath: string, options: RokuDeployOptions, formData = {} as T): requestType.OptionsWithUrl { options = this.getOptions(options); let url = `http://${options.host}:${options.packagePort}/${requestPath}`; let baseRequestOptions = { @@ -634,7 +638,7 @@ export class RokuDeploy { * Centralized function for handling GET http requests * @param params */ - private async doGetRequest(params: any) { + private async doGetRequest(params: requestType.OptionsWithUrl) { let results: { response: any; body: any } = await new Promise((resolve, reject) => { request.get(params, (err, resp, body) => { if (err) { @@ -676,20 +680,25 @@ export class RokuDeploy { let errorRegex = /Shell\.create\('Roku\.Message'\)\.trigger\('[\w\s]+',\s+'(\w+)'\)\.trigger\('[\w\s]+',\s+'(.*?)'\)/igm; let match: RegExpExecArray; - // eslint-disable-next-line no-cond-assign - while (match = errorRegex.exec(body)) { + while ((match = errorRegex.exec(body))) { let [, messageType, message] = match; switch (messageType.toLowerCase()) { - case 'error': - result.errors.push(message); + case RokuMessageType.error: + if (!result.errors.includes(message)) { + result.errors.push(message); + } break; - case 'info': - result.infos.push(message); + case RokuMessageType.info: + if (!result.infos.includes(message)) { + result.infos.push(message); + } break; - case 'success': - result.successes.push(message); + case RokuMessageType.success: + if (!result.successes.includes(message)) { + result.successes.push(message); + } break; default: @@ -697,6 +706,53 @@ export class RokuDeploy { } } + let jsonParseRegex = /JSON\.parse\(('.+')\);/igm; + let jsonMatch: RegExpExecArray; + + while ((jsonMatch = jsonParseRegex.exec(body))) { + let [, jsonString] = jsonMatch; + let jsonObject = parseJsonc(jsonString); + if (typeof jsonObject === 'object' && !Array.isArray(jsonObject) && jsonObject !== null) { + let messages = jsonObject.messages; + + if (!Array.isArray(messages)) { + continue; + } + + for (let messageObject of messages) { + // Try to duck type the object to make sure it is some form of message to be displayed + if (typeof messageObject.type === 'string' && messageObject.text_type === 'text' && typeof messageObject.text === 'string') { + const messageType: string = messageObject.type; + const text: string = messageObject.text; + switch (messageType.toLowerCase()) { + case RokuMessageType.error: + if (!result.errors.includes(text)) { + result.errors.push(text); + } + break; + + case RokuMessageType.info: + if (!result.infos.includes(text)) { + result.infos.push(text); + } + break; + + case RokuMessageType.success: + if (!result.successes.includes(text)) { + result.successes.push(text); + } + + break; + + default: + break; + } + } + } + } + + } + return result; } @@ -937,26 +993,76 @@ export class RokuDeploy { return outPkgFilePath; } - public async getDeviceInfo(options?: RokuDeployOptions) { - options = this.getOptions(options); + /** + * Get the `device-info` response from a Roku device + * @param host the host or IP address of the Roku + * @param port the port to use for the ECP request (defaults to 8060) + */ + public async getDeviceInfo(options?: { enhance: true } & GetDeviceInfoOptions): Promise; + public async getDeviceInfo(options?: GetDeviceInfoOptions): Promise + public async getDeviceInfo(options: GetDeviceInfoOptions) { + options = this.getOptions(options) as any; - const requestOptions = { - url: `http://${options.host}:${options.remotePort}/query/device-info`, - timeout: options.timeout - }; - let results = await this.doGetRequest(requestOptions); + //if the host is a DNS name, look up the IP address + try { + options.host = await util.dnsLookup(options.host); + } catch (e) { + //try using the host as-is (it'll probably fail...) + } + + const url = `http://${options.host}:${options.remotePort}/query/device-info`; + + let response = await this.doGetRequest({ + url: url, + timeout: options.timeout, + headers: { + 'User-Agent': 'https://github.com/RokuCommunity/roku-deploy' + } + }); try { - const parsedContent = await xml2js.parseStringPromise(results.body, { + const parsedContent = await xml2js.parseStringPromise(response.body, { explicitArray: false }); - return parsedContent['device-info']; + // clone the data onto an object because xml2js somehow makes this object not an object??? + let deviceInfo = { + ...parsedContent['device-info'] + } as Record; + + if (options.enhance) { + const result = {}; + // sanitize/normalize values to their native formats, and also convert property names to camelCase + for (let key in deviceInfo) { + result[lodash.camelCase(key)] = this.normalizeDeviceInfoFieldValue(deviceInfo[key]); + } + deviceInfo = result; + } + return deviceInfo; } catch (e) { - throw new errors.UnparsableDeviceResponseError('Could not retrieve device info', results); + throw new errors.UnparsableDeviceResponseError('Could not retrieve device info', response); + } + } + + /** + * Normalize a deviceInfo field value. This includes things like converting boolean strings to booleans, number strings to numbers, + * decoding HtmlEntities, etc. + * @param deviceInfo + */ + public normalizeDeviceInfoFieldValue(value: any) { + let num: number; + // convert 'true' and 'false' string values to boolean + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } else if (value.trim() !== '' && !isNaN(num = Number(value))) { + return num; + } else { + return util.decodeHtmlEntities(value); } } public async getDevId(options?: RokuDeployOptions) { - const deviceInfo = await this.getDeviceInfo(options); + const deviceInfo = await this.getDeviceInfo(options as any); return deviceInfo['keyed-developer-id']; } @@ -1086,6 +1192,12 @@ export interface RokuMessages { successes: string[]; } +enum RokuMessageType { + success = 'success', + info = 'info', + error = 'error' +} + export const DefaultFiles = [ 'source/**/*.*', 'components/**/*.*', @@ -1122,3 +1234,23 @@ export interface TakeScreenshotOptions { */ outFile?: string; } + +export interface GetDeviceInfoOptions { + /** + * The hostname or IP address to use for the device-info URL + */ + host: string; + /** + * The port to use to send the device-info request (defaults to the standard 8060 ECP port) + */ + remotePort?: number; + /** + * The number of milliseconds at which point this request should timeout and return a rejected promise + */ + timeout?: number; + /** + * Should the device-info be enhanced by camel-casing the property names and converting boolean strings to booleans and number strings to numbers? + * @default false + */ + enhance?: boolean; +} diff --git a/src/index.ts b/src/index.ts index e9b719b..dff33ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * from './RokuDeploy'; export * from './util'; export * from './RokuDeployOptions'; export * from './Errors'; +export * from './DeviceInfo'; //create a new static instance of RokuDeploy, and export those functions for backwards compatibility export const rokuDeploy = new RokuDeploy(); diff --git a/src/util.spec.ts b/src/util.spec.ts index b9c8626..15c4fc9 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -3,10 +3,18 @@ import { expect } from 'chai'; import * as fsExtra from 'fs-extra'; import { tempDir } from './testUtils.spec'; import * as path from 'path'; +import * as dns from 'dns'; +import { createSandbox } from 'sinon'; +const sinon = createSandbox(); describe('util', () => { beforeEach(() => { fsExtra.emptyDirSync(tempDir); + sinon.restore(); + }); + + afterEach(() => { + sinon.restore(); }); describe('isFile', () => { @@ -231,4 +239,50 @@ describe('util', () => { util['filterPaths']('*', [], '', 2); }); }); + + describe('dnsLookup', () => { + it('returns ip address for hostname', async () => { + sinon.stub(dns.promises, 'lookup').returns(Promise.resolve({ + address: '1.2.3.4', + family: undefined + })); + + expect( + await util.dnsLookup('some-host', true) + ).to.eql('1.2.3.4'); + }); + + it('returns ip address for ip address', async () => { + sinon.stub(dns.promises, 'lookup').returns(Promise.resolve({ + address: '1.2.3.4', + family: undefined + })); + + expect( + await util.dnsLookup('some-host', true) + ).to.eql('1.2.3.4'); + }); + + it('returns given value if the lookup failed', async () => { + sinon.stub(dns.promises, 'lookup').returns(Promise.resolve({ + address: undefined, + family: undefined + })); + + expect( + await util.dnsLookup('some-host', true) + ).to.eql('some-host'); + }); + }); + + describe('decodeHtmlEntities', () => { + it('decodes values properly', () => { + expect(util.decodeHtmlEntities(' ')).to.eql(' '); + expect(util.decodeHtmlEntities('&')).to.eql('&'); + expect(util.decodeHtmlEntities('"')).to.eql('"'); + expect(util.decodeHtmlEntities('<')).to.eql('<'); + expect(util.decodeHtmlEntities('>')).to.eql('>'); + expect(util.decodeHtmlEntities(''')).to.eql(`'`); + }); + }); }); diff --git a/src/util.ts b/src/util.ts index 36c78d1..a0fe9f6 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,9 +1,10 @@ -/* eslint-disable */ import * as fsExtra from 'fs-extra'; import * as path from 'path'; import * as fs from 'fs'; +import * as dns from 'dns'; import * as micromatch from 'micromatch'; -import fastGlob = require("fast-glob") +// eslint-disable-next-line @typescript-eslint/no-require-imports +import fastGlob = require('fast-glob'); export class Util { /** @@ -151,7 +152,7 @@ export class Util { cwd: cwd, absolute: true, followSymbolicLinks: true, - onlyFiles: true, + onlyFiles: true }); } }); @@ -189,6 +190,44 @@ export class Util { } } } + + /* + * Look up the ip address for a hostname. This is cached for the lifetime of the app, or bypassed with the `skipCache` parameter + * @param host + * @param skipCache + * @returns + */ + public async dnsLookup(host: string, skipCache = false) { + if (!this.dnsCache.has(host) || skipCache) { + const result = await dns.promises.lookup(host); + this.dnsCache.set(host, result.address ?? host); + } + return this.dnsCache.get(host); + } + + private dnsCache = new Map(); + + /** + * Decode HTML entities like   ' to its original character + */ + public decodeHtmlEntities(encodedString: string) { + let translateRegex = /&(nbsp|amp|quot|lt|gt);/g; + let translate = { + 'nbsp': ' ', + 'amp': '&', + 'quot': '"', + 'lt': '<', + 'gt': '>' + }; + + return encodedString.replace(translateRegex, (match, entity) => { + return translate[entity]; + }).replace(/&#(\d+);/gi, (match, numStr) => { + let num = parseInt(numStr, 10); + return String.fromCharCode(num); + }); + } + } export let util = new Util(); @@ -206,4 +245,3 @@ export function standardizePath(stringParts, ...expressions: any[]) { result.join('') ); } -