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('')
);
}
-