Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ module.exports = {
'error',
'always'
],
'@typescript-eslint/parameter-properties': 'off',
'@typescript-eslint/prefer-readonly': 'off',
'@typescript-eslint/prefer-readonly-parameter-types': 'off',
'@typescript-eslint/promise-function-async': 'off',
Expand Down Expand Up @@ -111,6 +112,7 @@ module.exports = {
'no-constant-condition': 'off',
'no-console': 'off',
'no-continue': 'off',
'no-duplicate-imports': 'off',
'no-else-return': 'off',
'no-empty': 'off',
'no-implicit-coercion': 'off',
Expand Down
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
"**/bower_components": true,
"**/*.code-search": true,
"**/dist": true
}
}
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
1,425 changes: 86 additions & 1,339 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,15 @@
"package": "npm run build && npm pack"
},
"dependencies": {
"@types/request": "^2.48.13",
"chalk": "^2.4.2",
"fast-glob": "^3.2.12",
"fs-extra": "^7.0.1",
"is-glob": "^4.0.3",
"jsonc-parser": "^2.3.0",
"jszip": "^3.6.0",
"micromatch": "^4.0.4",
"needle": "^3.5.0",
"picomatch": "^2.3.2",
"postman-request": "^2.88.1-postman.48",
"semver": "^7.7.3",
"xml2js": "^0.5.0"
},
Expand All @@ -38,6 +37,7 @@
"@types/is-glob": "^4.0.2",
"@types/micromatch": "^4.0.2",
"@types/mocha": "^10.0.0",
"@types/needle": "^3.3.0",
"@types/node": "^18.19.0",
"@types/picomatch": "^2.3.4",
"@types/semver": "^7.7.1",
Expand Down
6 changes: 3 additions & 3 deletions src/Errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { HttpResponse, RokuMessages } from './RokuDeploy';
import type * as requestType from 'request';
import type { RequestOptions } from './request';

export class InvalidDeviceResponseCodeError extends Error {
constructor(message: string, public results?: any) {
Expand Down Expand Up @@ -87,7 +87,7 @@ export class UpdateCheckRequiredError extends Error {

constructor(
public response: HttpResponse,
public requestOptions: requestType.OptionsWithUrl,
public requestOptions: RequestOptions,
public cause?: Error
) {
super();
Expand All @@ -107,7 +107,7 @@ export class ConnectionResetError extends Error {

static MESSAGE = `The Roku device ended the connection unexpectedly and may need to check for updates before accepting connections. Please navigate to System Settings and check for updates and then try again.\n\nhttps://support.roku.com/article/208755668.`;

constructor(error: Error, requestOptions: requestType.OptionsWithUrl) {
constructor(error: Error, requestOptions: RequestOptions) {
super();
this.message = ConnectionResetError.MESSAGE;
this.cause = error;
Expand Down
232 changes: 201 additions & 31 deletions src/RokuDeploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { defer, util, standardizePath as s } from './util';
import type { FileEntry, RokuDeployOptions } from './RokuDeployOptions';
import { cwd, expectPathExists, expectPathNotExists, expectThrowsAsync, outDir, rootDir, stagingDir, tempDir, writeFiles } from './testUtils.spec';
import { createSandbox } from 'sinon';
import * as r from 'postman-request';
import type * as requestType from 'request';
const request = r as typeof requestType;
import * as needle from 'needle';
import { request } from './request';
import { PassThrough } from 'stream';

const sinon = createSandbox();

Expand Down Expand Up @@ -196,6 +196,189 @@ describe('RokuDeploy', () => {
});
});

describe('error results structure (postman-request compatibility)', () => {
//These tests drive RokuDeploy through the REAL needle shim (by stubbing the low-level
//`needle`), then assert the exact shape of the `results`/`response` object attached to
//each thrown error. roku-deploy historically exposed a `request`/`postman-request`-shaped
//object here, and consumers read specific paths off it (`results.response.statusCode`,
//`results.response.headers.server`, `results.response.request.host`, string `results.body`).
//Getting any of these wrong is a BREAKING API change, so they're pinned down explicitly.

/** Stub needle.post to deliver the given needle-style response + body */
function stubNeedlePost(response: any, body: any, error: any = null) {
return sinon.stub(needle, 'post').callsFake(((url: string, data: any, opts: any, callback: any) => {
process.nextTick(callback, error, response, body);
return {} as any;
}) as any);
}

/** Stub needle.get (callback form) to deliver the given needle-style response + body */
function stubNeedleGet(response: any, body: any, error: any = null) {
return sinon.stub(needle, 'get').callsFake(((url: string, opts: any, callback: any) => {
if (callback) {
process.nextTick(callback, error, response, body);
}
return new PassThrough() as any;
}) as any);
}

describe('UnauthorizedDeviceResponseError (401)', () => {
it('attaches results with response.statusCode, request.host, and a string body', async () => {
stubNeedlePost({ statusCode: 401, headers: {} }, Buffer.alloc(0));
let caught: any;
try {
await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any);
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.UnauthorizedDeviceResponseError);
//message embeds the host pulled off results.response.request.host
expect(caught.message).to.equal(`Unauthorized. Please verify credentials for host '1.2.3.4'`);
//the postman-style results object
expect(caught.results).to.be.an('object');
expect(caught.results.response.statusCode).to.equal(401);
expect(caught.results.response.request.host).to.equal('1.2.3.4');
expect(caught.results.response.headers).to.be.an('object');
expect(caught.results.body).to.be.a('string');
});

it('attaches the guaranteed results structure (verified against postman-request 3.17.6)', async () => {
//The parity harness confirmed the old postman-request build attached `{ response, body }`
//where `response` is the underlying http.IncomingMessage. The shim reproduces that (it
//returns needle's IncomingMessage), so we assert the GUARANTEED fields a consumer reads
//rather than deep-equaling the full IncomingMessage surface (which we intentionally keep).
const headers = { 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' };
stubNeedlePost({ statusCode: 401, headers: headers }, Buffer.alloc(0));
let caught: any;
try {
await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any);
} catch (e) {
caught = e;
}
expect(caught.results.body).to.equal('');
expect(caught.results.response.statusCode).to.equal(401);
expect(caught.results.response.headers).to.eql({ 'content-length': '0', 'www-authenticate': 'Digest realm="rokudev"' });
expect(caught.results.response.body).to.equal('');
expect(caught.results.response.request.host).to.equal('1.2.3.4');
expect(caught.results.response.request.href).to.equal('http://1.2.3.4:80/plugin_install');
});
});

describe('InvalidDeviceResponseCodeError (non-200)', () => {
it('attaches results with the offending statusCode and message', async () => {
stubNeedlePost({ statusCode: 500, headers: {} }, 'oops');
let caught: any;
try {
await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any);
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.InvalidDeviceResponseCodeError);
expect(caught.message).to.equal('Invalid response code: 500');
expect(caught.results.response.statusCode).to.equal(500);
expect(caught.results.body).to.equal('oops');
});
});

describe('UnparsableDeviceResponseError', () => {
it('is thrown (with results) when the response object is missing', async () => {
//needle delivers no response object and no body
stubNeedlePost(undefined, undefined);
let caught: any;
try {
await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any);
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.UnparsableDeviceResponseError);
});
});

describe('FailedDeviceResponseError (roku message in body)', () => {
it('attaches the parsed rokuMessages object (errors/infos/successes)', async () => {
const body = getFakeResponseBody(`
Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Failure: Form Error: "archive" Field Not Found').trigger('Render', node);
`);
stubNeedlePost({ statusCode: 200, headers: {} }, body);
let caught: any;
try {
await rokuDeploy.deleteInstalledChannel({ host: '1.2.3.4', password: 'aaaa' } as any);
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.FailedDeviceResponseError);
expect(caught.message).to.equal('Failure: Form Error: "archive" Field Not Found');
//for this error, `results` is the rokuMessages object, not the http results
expect(caught.results).to.eql({
errors: ['Failure: Form Error: "archive" Field Not Found'],
infos: [],
successes: []
});
});
});

describe('getDeviceInfo -> EcpNetworkAccessModeDisabledError', () => {
it('detects a Roku server header on the error results', async () => {
//device-info is a GET; a Roku server header on a failing response means ECP is disabled
stubNeedleGet({ statusCode: 403, headers: { server: 'Roku/12.0' } }, 'forbidden');
let caught: any;
try {
await rokuDeploy.getDeviceInfo({ host: '1.2.3.4' });
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.EcpNetworkAccessModeDisabledError);
});

it('does NOT treat a non-Roku server header as ECP-disabled', async () => {
stubNeedleGet({ statusCode: 500, headers: { server: 'Apache' } }, 'err');
let caught: any;
try {
await rokuDeploy.getDeviceInfo({ host: '1.2.3.4' });
} catch (e) {
caught = e;
}
//the original (InvalidDeviceResponseCodeError from checkRequest) bubbles up, with results intact
expect(caught).to.be.instanceof(errors.InvalidDeviceResponseCodeError);
expect(caught.results.response.headers.server).to.equal('Apache');
});
});

describe('publish error mapping', () => {
beforeEach(() => {
options.host = '1.2.3.4';
options.failOnCompileError = true;
options.retainDeploymentArchive = true;
//make a dummy zip so publish gets past the file-exists check
fsExtra.outputFileSync(rokuDeploy.getOutputZipFilePath(options), 'fake-zip-content');
});

it('maps a 577 response to UpdateCheckRequiredError', async () => {
stubNeedlePost({ statusCode: 577, headers: {} }, '');
let caught: any;
try {
await rokuDeploy.publish(options);
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.UpdateCheckRequiredError);
});

it('maps an ECONNRESET to ConnectionResetError', async () => {
const resetError: any = new Error('socket hang up');
resetError.code = 'ECONNRESET';
stubNeedlePost(undefined, undefined, resetError);
let caught: any;
try {
await rokuDeploy.publish(options);
} catch (e) {
caught = e;
}
expect(caught).to.be.instanceof(errors.ConnectionResetError);
});
});
});

describe('getRokuMessagesFromResponseBody', () => {
it('exits on unknown message type', () => {
const result = rokuDeploy['getRokuMessagesFromResponseBody'](`
Expand Down Expand Up @@ -4445,43 +4628,30 @@ describe('RokuDeploy', () => {
expect(currentVersion).to.exist.and.to.match(/^\d+\.\d+\.\d+.*/);
});

it('works when params is undefined', () => {
//undefined
expect(
rokuDeploy['setUserAgentIfMissing'](undefined)
).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } });
});

it('works when params has no header container', () => {
expect(
rokuDeploy['setUserAgentIfMissing']({} as any)
).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } });
it('adds the header container when params has none', () => {
const params = {} as any;
rokuDeploy['setUserAgentIfMissing'](params);
expect(params).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } });
});

it('works when params has empty header container', () => {
expect(
rokuDeploy['setUserAgentIfMissing']({} as any)
).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } });
it('sets the user agent when params has an empty header container', () => {
const params = { headers: {} } as any;
rokuDeploy['setUserAgentIfMissing'](params);
expect(params).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } });
});

it('works when params has existing header container with no user agent', () => {
expect(
rokuDeploy['setUserAgentIfMissing']({ headers: {} } as any)
).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } });
});

it('works when params has existing header container with user agent', () => {
expect(
rokuDeploy['setUserAgentIfMissing']({ headers: { 'User-Agent': 'some-other-user-agent' } } as any)
).to.eql({ headers: { 'User-Agent': 'some-other-user-agent' } });
it('does not overwrite an existing user agent', () => {
const params = { headers: { 'User-Agent': 'some-other-user-agent' } } as any;
rokuDeploy['setUserAgentIfMissing'](params);
expect(params).to.eql({ headers: { 'User-Agent': 'some-other-user-agent' } });
});

it('works when we fail to load package version', () => {
sinon.stub(fsExtra, 'readJsonSync').throws(new Error('Unable to read package.json'));
rokuDeploy['_packageVersion'] = undefined;
expect(
rokuDeploy['setUserAgentIfMissing']({} as any)
).to.eql({ headers: { 'User-Agent': 'roku-deploy/unknown' } });
const params = {} as any;
rokuDeploy['setUserAgentIfMissing'](params);
expect(params).to.eql({ headers: { 'User-Agent': 'roku-deploy/unknown' } });
});
});

Expand Down
Loading
Loading