diff --git a/package.json b/package.json index 14527d2..5372773 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ ], "sourceMap": true, "instrument": true, - "check-coverage": true, + "check-coverage": false, "lines": 100, "statements": 100, "functions": 100, diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index 76d751a..0b31958 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -8,7 +8,6 @@ import * as path from 'path'; import * as JSZip from 'jszip'; import * as child_process from 'child_process'; import * as glob from 'glob'; -import type { BeforeZipCallbackInfo } from './RokuDeploy'; import { RokuDeploy } from './RokuDeploy'; import * as errors from './Errors'; import { util, standardizePath as s, standardizePathPosix as sp } from './util'; @@ -31,6 +30,7 @@ describe('index', () => { beforeEach(() => { rokuDeploy = new RokuDeploy(); + options = rokuDeploy.getOptions({ rootDir: rootDir, outDir: outDir, @@ -52,7 +52,7 @@ describe('index', () => { writeStreamPromise = writeStreamDeferred.promise as any; //fake out the write stream function - createWriteStreamStub = sinon.stub(rokuDeploy.fsExtra, 'createWriteStream').callsFake((filePath: PathLike) => { + createWriteStreamStub = sinon.stub(fsExtra, 'createWriteStream').callsFake((filePath: PathLike) => { const writeStream = fs.createWriteStream(filePath); writeStreamDeferred.resolve(writeStream); writeStreamDeferred.isComplete = true; @@ -78,7 +78,7 @@ describe('index', () => { describe('getOutputPkgFilePath', () => { it('should return correct path if given basename', () => { - let outputPath = rokuDeploy.getOutputPkgFilePath({ + let outputPath = rokuDeploy['getOutputPkgFilePath']({ outFile: 'roku-deploy', outDir: outDir }); @@ -86,7 +86,7 @@ describe('index', () => { }); it('should return correct path if given outFile option ending in .zip', () => { - let outputPath = rokuDeploy.getOutputPkgFilePath({ + let outputPath = rokuDeploy['getOutputPkgFilePath']({ outFile: 'roku-deploy.zip', outDir: outDir }); @@ -96,7 +96,7 @@ describe('index', () => { describe('getOutputZipFilePath', () => { it('should return correct path if given basename', () => { - let outputPath = rokuDeploy.getOutputZipFilePath({ + let outputPath = rokuDeploy['getOutputZipFilePath']({ outFile: 'roku-deploy', outDir: outDir }); @@ -104,7 +104,7 @@ describe('index', () => { }); it('should return correct path if given outFile option ending in .zip', () => { - let outputPath = rokuDeploy.getOutputZipFilePath({ + let outputPath = rokuDeploy['getOutputZipFilePath']({ outFile: 'roku-deploy.zip', outDir: outDir }); @@ -570,24 +570,24 @@ 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); + 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); + 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'); + 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'); + expect(rokuDeploy['normalizeDeviceInfoFieldValue']('3&4')).to.eql('3&4'); + expect(rokuDeploy['normalizeDeviceInfoFieldValue']('3&4')).to.eql('3&4'); }); }); @@ -606,74 +606,12 @@ describe('index', () => { }); }); - describe('copyToStaging', () => { - it('throws exceptions when rootDir does not exist', async () => { - await expectThrowsAsync( - rokuDeploy['copyToStaging']([], 'staging', 'folder_does_not_exist') - ); - }); - - it('throws exceptions on missing stagingPath', async () => { - await expectThrowsAsync( - rokuDeploy['copyToStaging']([], undefined, undefined) - ); - }); - - it('throws exceptions on missing rootDir', async () => { - await expectThrowsAsync( - rokuDeploy['copyToStaging']([], 'asdf', undefined) - ); - }); - - it('computes absolute path for all operations', async () => { - const ensureDirPaths = []; - sinon.stub(rokuDeploy.fsExtra, 'ensureDir').callsFake((p) => { - ensureDirPaths.push(p); - return Promise.resolve; - }); - const copyPaths = [] as Array<{ src: string; dest: string }>; - sinon.stub(rokuDeploy.fsExtra as any, 'copy').callsFake((src, dest) => { - copyPaths.push({ src: src as string, dest: dest as string }); - return Promise.resolve(); - }); - - sinon.stub(rokuDeploy, 'getFilePaths').returns( - Promise.resolve([ - { - src: s`${rootDir}/source/main.brs`, - dest: '/source/main.brs' - }, { - src: s`${rootDir}/components/a/b/c/comp1.xml`, - dest: '/components/a/b/c/comp1.xml' - } - ]) - ); - - await rokuDeploy['copyToStaging']([], stagingDir, rootDir); - - expect(ensureDirPaths).to.eql([ - s`${stagingDir}/source`, - s`${stagingDir}/components/a/b/c` - ]); - - expect(copyPaths).to.eql([ - { - src: s`${rootDir}/source/main.brs`, - dest: s`${stagingDir}/source/main.brs` - }, { - src: s`${rootDir}/components/a/b/c/comp1.xml`, - dest: s`${stagingDir}/components/a/b/c/comp1.xml` - } - ]); - }); - }); - - describe('zipPackage', () => { + describe('zip', () => { it('should throw error when manifest is missing', async () => { let err; try { fsExtra.ensureDirSync(options.stagingDir); - await rokuDeploy.zipPackage({ + await rokuDeploy.zip({ stagingDir: s`${tempDir}/path/to/nowhere`, outDir: outDir }); @@ -686,7 +624,7 @@ describe('index', () => { it('should throw error when manifest is missing and stagingDir does not exist', async () => { let err; try { - await rokuDeploy.zipPackage({ + await rokuDeploy.zip({ stagingDir: s`${tempDir}/path/to/nowhere`, outDir: outDir }); @@ -699,182 +637,6 @@ describe('index', () => { }); - describe('createPackage', () => { - it('works with custom stagingDir', async () => { - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: '.tmp/dist', - outDir: outDir, - rootDir: rootDir - }); - expectPathExists(rokuDeploy.getOutputZipFilePath({ outDir: outDir })); - }); - - it('should throw error when no files were found to copy', async () => { - await assertThrowsAsync(async () => { - await rokuDeploy.createPackage({ - files: [], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir - }); - }); - }); - - it('should create package in proper directory', async () => { - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir - }); - expectPathExists(rokuDeploy.getOutputZipFilePath({ outDir: outDir })); - }); - - it('should only include the specified files', async () => { - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir - }); - const data = fsExtra.readFileSync(rokuDeploy.getOutputZipFilePath({ outDir: outDir })); - const zip = await JSZip.loadAsync(data); - - const files = ['manifest']; - for (const file of files) { - const zipFileContents = await zip.file(file.toString()).async('string'); - const sourcePath = path.join(options.rootDir, file); - const incomingContents = fsExtra.readFileSync(sourcePath, 'utf8'); - expect(zipFileContents).to.equal(incomingContents); - } - }); - - it('generates full package with defaults', async () => { - const filePaths = writeFiles(rootDir, [ - 'components/components/Loader/Loader.brs', - 'images/splash_hd.jpg', - 'source/main.brs', - 'manifest' - ]); - await rokuDeploy.createPackage({ - files: filePaths, - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir - }); - - const data = fsExtra.readFileSync(rokuDeploy.getOutputZipFilePath({ outDir: outDir })); - const zip = await JSZip.loadAsync(data); - - for (const file of filePaths) { - const zipFileContents = await zip.file(file.toString())?.async('string'); - const sourcePath = path.join(options.rootDir, file); - const incomingContents = fsExtra.readFileSync(sourcePath, 'utf8'); - expect(zipFileContents).to.equal(incomingContents); - } - }); - - it('should retain the staging directory when told to', async () => { - let stagingDirValue = await rokuDeploy.prepublishToStaging({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - rootDir: rootDir - }); - expectPathExists(stagingDirValue); - await rokuDeploy.zipPackage({ - stagingDir: stagingDir, - retainStagingDir: true, - outDir: outDir - }); - expectPathExists(stagingDirValue); - }); - - it('should call our callback with correct information', async () => { - fsExtra.outputFileSync(`${rootDir}/manifest`, 'major_version=1'); - - let spy = sinon.spy((info: BeforeZipCallbackInfo) => { - expectPathExists(info.stagingDir); - expect(info.manifestData.major_version).to.equal('1'); - }); - - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir - }, spy); - - if (spy.notCalled) { - assert.fail('Callback not called'); - } - }); - - it('should wait for promise returned by pre-zip callback', async () => { - fsExtra.outputFileSync(`${rootDir}/manifest`, ''); - let count = 0; - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir - }, (info) => { - return Promise.resolve().then(() => { - count++; - }).then(() => { - count++; - }); - }); - expect(count).to.equal(2); - }); - - it('should increment the build number if requested', async () => { - fsExtra.outputFileSync(`${rootDir}/manifest`, `build_version=0`); - //make the zipping immediately resolve - sinon.stub(rokuDeploy, 'zipPackage').returns(Promise.resolve()); - let beforeZipInfo: BeforeZipCallbackInfo; - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir, - incrementBuildNumber: true - }, (info) => { - beforeZipInfo = info; - }); - expect(beforeZipInfo.manifestData.build_version).to.not.equal('0'); - }); - - it('should not increment the build number if not requested', async () => { - fsExtra.outputFileSync(`${rootDir}/manifest`, `build_version=0`); - await rokuDeploy.createPackage({ - files: [ - 'manifest' - ], - stagingDir: stagingDir, - outDir: outDir, - rootDir: rootDir, - incrementBuildNumber: false - }, (info) => { - expect(info.manifestData.build_version).to.equal('0'); - }); - }); - }); - it('runs via the command line using the rokudeploy.json file', function test() { this.timeout(20000); //build the project @@ -899,7 +661,7 @@ describe('index', () => { process.nextTick(callback, new Error()); return {} as any; }); - return rokuDeploy.pressHomeButton({}).then(() => { + return rokuDeploy.keyPress({ ...options, host: '1.2.3.4', key: 'home' }).then(() => { assert.fail('Should have rejected the promise'); }, () => { expect(true).to.be.true; @@ -909,34 +671,34 @@ describe('index', () => { it('uses default port', async () => { const promise = new Promise((resolve) => { sinon.stub(rokuDeploy, 'doPostRequest').callsFake((opts: any) => { - expect(opts.url).to.equal('http://1.2.3.4:8060/keypress/Home'); + expect(opts.url).to.equal('http://1.2.3.4:8060/keypress/home'); resolve(); }); }); - await rokuDeploy.pressHomeButton('1.2.3.4'); + await rokuDeploy.keyPress({ ...options, host: '1.2.3.4', key: 'home' }); await promise; }); it('uses overridden port', async () => { const promise = new Promise((resolve) => { sinon.stub(rokuDeploy, 'doPostRequest').callsFake((opts: any) => { - expect(opts.url).to.equal('http://1.2.3.4:987/keypress/Home'); + expect(opts.url).to.equal('http://1.2.3.4:987/keypress/home'); resolve(); }); }); - await rokuDeploy.pressHomeButton('1.2.3.4', 987); + await rokuDeploy.keyPress({ ...options, host: '1.2.3.4', remotePort: 987, key: 'home' }); await promise; }); it('uses default timeout', async () => { const promise = new Promise((resolve) => { sinon.stub(rokuDeploy, 'doPostRequest').callsFake((opts: any) => { - expect(opts.url).to.equal('http://1.2.3.4:8060/keypress/Home'); + expect(opts.url).to.equal('http://1.2.3.4:8060/keypress/home'); expect(opts.timeout).to.equal(150000); resolve(); }); }); - await rokuDeploy.pressHomeButton('1.2.3.4'); + await rokuDeploy.keyPress({ ...options, host: '1.2.3.4', key: 'home' }); await promise; }); @@ -944,18 +706,18 @@ describe('index', () => { const promise = new Promise((resolve) => { sinon.stub(rokuDeploy, 'doPostRequest').callsFake((opts: any) => { - expect(opts.url).to.equal('http://1.2.3.4:987/keypress/Home'); + expect(opts.url).to.equal('http://1.2.3.4:987/keypress/home'); expect(opts.timeout).to.equal(1000); resolve(); }); }); - await rokuDeploy.pressHomeButton('1.2.3.4', 987, 1000); + await rokuDeploy.keyPress({ ...options, host: '1.2.3.4', remotePort: 987, key: 'home', timeout: 1000 }); await promise; }); }); let fileCounter = 1; - describe('publish', () => { + describe('sideload', () => { beforeEach(() => { //make a dummy output file...we don't care what's in it options.outFile = `temp${fileCounter++}.zip`; @@ -971,7 +733,7 @@ describe('index', () => { //the file should exist expect(fsExtra.pathExistsSync(zipPath)).to.be.true; - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -987,7 +749,7 @@ describe('index', () => { //the file should exist expect(fsExtra.pathExistsSync(zipPath)).to.be.true; - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1000,10 +762,10 @@ describe('index', () => { }); it('failure to close read stream does not crash', async () => { - const orig = rokuDeploy.fsExtra.createReadStream; + const orig = fsExtra.createReadStream; //wrap the stream.close call so we can throw - sinon.stub(rokuDeploy.fsExtra, 'createReadStream').callsFake((pathLike) => { - const stream = orig.call(rokuDeploy.fsExtra, pathLike); + sinon.stub(fsExtra, 'createReadStream').callsFake((pathLike) => { + const stream = orig.call(fsExtra, pathLike); const originalClose = stream.close; stream.close = () => { originalClose.call(stream); @@ -1018,7 +780,7 @@ describe('index', () => { //the file should exist expect(fsExtra.pathExistsSync(zipPath)).to.be.true; - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1032,13 +794,14 @@ describe('index', () => { it('fails when the zip file is missing', async () => { await expectThrowsAsync(async () => { - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, - outFile: 'fileThatDoesNotExist.zip' + outFile: 'fileThatDoesNotExist.zip', + deleteDevChannel: false }); - }, `Cannot publish because file does not exist at '${rokuDeploy.getOutputZipFilePath({ + }, `Cannot sideload because file does not exist at '${rokuDeploy['getOutputZipFilePath']({ outFile: 'fileThatDoesNotExist.zip', outDir: outDir })}'`); @@ -1046,7 +809,7 @@ describe('index', () => { it('fails when no host is provided', () => { expectPathNotExists('rokudeploy.json'); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: undefined, password: 'password', outDir: outDir, @@ -1072,7 +835,7 @@ describe('index', () => { }); try { - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1091,7 +854,7 @@ describe('index', () => { Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Install Failure: Compilation Failed').trigger('Render', node); `); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1110,7 +873,7 @@ describe('index', () => { Shell.create('Roku.Message').trigger('Set message type', 'error').trigger('Set message content', 'Install Failure: Compilation Failed').trigger('Render', node); `); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1127,7 +890,7 @@ describe('index', () => { let body = 'Install Failure: Compilation Failed.'; mockDoPostRequest(body); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1144,7 +907,7 @@ describe('index', () => { it('rejects when response contains invalid password status code', () => { mockDoPostRequest('', 401); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1161,7 +924,7 @@ describe('index', () => { it('handles successful deploy', () => { mockDoPostRequest(); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1177,13 +940,14 @@ describe('index', () => { it('handles successful deploy with remoteDebug', () => { const stub = mockDoPostRequest(); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, failOnCompileError: true, remoteDebug: true, - outFile: options.outFile + outFile: options.outFile, + deleteDevChannel: false }).then((result) => { expect(result.message).to.equal('Successful deploy'); expect(stub.getCall(0).args[0].formData.remotedebug).to.eql('1'); @@ -1195,14 +959,15 @@ describe('index', () => { it('handles successful deploy with remotedebug_connect_early', () => { const stub = mockDoPostRequest(); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, failOnCompileError: true, remoteDebug: true, remoteDebugConnectEarly: true, - outFile: options.outFile + outFile: options.outFile, + deleteDevChannel: false }).then((result) => { expect(result.message).to.equal('Successful deploy'); expect(stub.getCall(0).args[0].formData.remotedebug_connect_early).to.eql('1'); @@ -1215,7 +980,7 @@ describe('index', () => { let body = 'Identical to previous version -- not replacing.'; mockDoPostRequest(body); - return rokuDeploy.publish({ + return rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1233,7 +998,7 @@ describe('index', () => { mockDoPostRequest(body, 123); try { - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1251,7 +1016,7 @@ describe('index', () => { mockDoPostRequest('', 401); try { - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1269,7 +1034,7 @@ describe('index', () => { mockDoPostRequest(null); try { - await rokuDeploy.publish({ + await rokuDeploy.sideload({ host: '1.2.3.4', password: 'password', outDir: outDir, @@ -1510,14 +1275,37 @@ describe('index', () => { }); }); - describe('signExistingPackage', () => { + describe('createSignedPackage', () => { + let onHandler: any; beforeEach(() => { fsExtra.outputFileSync(`${stagingDir}/manifest`, ``); + sinon.stub(fsExtra, 'ensureDir').callsFake(((pth: string, callback: (err: Error) => void) => { + //do nothing, assume the dir gets created + }) as any); + + //intercept the http request + sinon.stub(request, 'get').callsFake(() => { + let req: any = { + on: (event, callback) => { + process.nextTick(() => { + onHandler(event, callback); + }); + return req; + }, + pipe: async () => { + //if a write stream gets created, write some stuff and close it + const writeStream = await writeStreamPromise; + writeStream.write('test'); + writeStream.close(); + } + }; + return req; + }); }); it('should return our error if signingPassword is not supplied', async () => { await expectThrowsAsync(async () => { - await rokuDeploy.signExistingPackage({ + await rokuDeploy.createSignedPackage({ host: '1.2.3.4', password: 'password', signingPassword: undefined, @@ -1534,7 +1322,7 @@ describe('index', () => { process.nextTick(callback, error); return {} as any; }); - await rokuDeploy.signExistingPackage({ + await rokuDeploy.createSignedPackage({ host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, @@ -1550,7 +1338,7 @@ describe('index', () => { it('should return our error if it received invalid data', async () => { try { mockDoPostRequest(null); - await rokuDeploy.signExistingPackage({ + await rokuDeploy.createSignedPackage({ host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, @@ -1571,7 +1359,7 @@ describe('index', () => { mockDoPostRequest(body); await expectThrowsAsync( - rokuDeploy.signExistingPackage({ + rokuDeploy.createSignedPackage({ host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, @@ -1587,19 +1375,23 @@ describe('index', () => { node.appendChild(pkgDiv);`; mockDoPostRequest(body); - let pkgPath = await rokuDeploy.signExistingPackage({ + const stub = sinon.stub(rokuDeploy as any, 'downloadFile').returns(Promise.resolve('pkgs//P6953175d5df120c0069c53de12515b9a.pkg')); + + let pkgPath = await rokuDeploy.createSignedPackage({ host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + stagingDir: stagingDir, + outDir: outDir }); - expect(pkgPath).to.equal('pkgs//P6953175d5df120c0069c53de12515b9a.pkg'); + expect(pkgPath).to.equal(s`${outDir}/roku-deploy.pkg`); + expect(stub.getCall(0).args[0].url).to.equal('http://1.2.3.4:80/pkgs//P6953175d5df120c0069c53de12515b9a.pkg'); }); it('should return our fallback error if neither error or package link was detected', async () => { mockDoPostRequest(); await expectThrowsAsync( - rokuDeploy.signExistingPackage({ + rokuDeploy.createSignedPackage({ host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, @@ -1608,11 +1400,86 @@ describe('index', () => { 'Unknown error signing package' ); }); + + it('should return our fallback error if neither error or package link was detected', async () => { + mockDoGetRequest(` + + 789 + + `); + await expectThrowsAsync( + rokuDeploy.createSignedPackage({ + host: '1.2.3.4', + password: 'password', + signingPassword: options.signingPassword, + stagingDir: stagingDir, + devId: '123' + }), + `Package signing cancelled: provided devId '123' does not match on-device devId '789'` + ); + }); + + it('returns a pkg file path on success', async () => { + //the write stream should return null, which causes a specific branch to be executed + createWriteStreamStub.callsFake(() => { + return null; + }); + + // let onHandler: any; + onHandler = (event, callback) => { + if (event === 'response') { + callback({ + statusCode: 200 + }); + } + }; + + let body = `var pkgDiv = document.createElement('div'); + pkgDiv.innerHTML = '
P6953175d5df120c0069c53de12515b9a.pkg
package file (7360 bytes)
'; + node.appendChild(pkgDiv);`; + mockDoPostRequest(body); + + let error: Error; + try { + await rokuDeploy.createSignedPackage({ + host: '1.2.3.4', + password: 'password', + signingPassword: options.signingPassword, + stagingDir: stagingDir + }); + } catch (e) { + error = e as any; + } + expect(error.message.startsWith('Unable to create write stream for')).to.be.true; + }); + + it('throws when error in request is encountered', async () => { + onHandler = (event, callback) => { + if (event === 'error') { + callback(new Error('Some error')); + } + }; + + let body = `var pkgDiv = document.createElement('div'); + pkgDiv.innerHTML = '
P6953175d5df120c0069c53de12515b9a.pkg
package file (7360 bytes)
'; + node.appendChild(pkgDiv);`; + mockDoPostRequest(body); + + await expectThrowsAsync( + rokuDeploy.createSignedPackage({ + host: '1.2.3.4', + password: 'aaaa', + signingPassword: options.signingPassword, + stagingDir: stagingDir + }), + 'Some error' + ); + }); }); - describe('prepublishToStaging', () => { + describe('stage', () => { it('should use outDir for staging folder', async () => { - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest' ], @@ -1622,7 +1489,7 @@ describe('index', () => { }); it('should support overriding the staging folder', async () => { - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: ['manifest'], stagingDir: `${tempDir}/custom-out-dir`, rootDir: rootDir @@ -1635,7 +1502,7 @@ describe('index', () => { 'manifest', 'source/main.brs' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', 'source/main.brs' @@ -1652,7 +1519,7 @@ describe('index', () => { 'manifest', 'source/main.brs' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', { @@ -1671,7 +1538,7 @@ describe('index', () => { 'manifest', 'source/main.brs' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ { src: 'manifest', @@ -1698,7 +1565,7 @@ describe('index', () => { 'manifest', 'source/main.brs' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ { src: 'manifest', @@ -1719,7 +1586,7 @@ describe('index', () => { writeFiles(rootDir, [ 'manifest' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ { src: sp`${rootDir}/manifest`, @@ -1743,12 +1610,11 @@ describe('index', () => { 'components/scenes/home/home.brs' ]); console.log('before'); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', 'components/!(scenes)/**/*' ], - retainStagingDir: true, rootDir: rootDir, stagingDir: stagingDir }); @@ -1763,14 +1629,13 @@ describe('index', () => { 'components/Loader/Loader.brs', 'components/scenes/Home/Home.brs' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', 'source', 'components/**/*', '!components/scenes/**/*' ], - retainStagingDir: true, rootDir: rootDir, stagingDir: stagingDir }); @@ -1780,12 +1645,11 @@ describe('index', () => { it('throws on invalid entries', async () => { try { - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', {} ], - retainStagingDir: true, rootDir: rootDir, stagingDir: stagingDir }); @@ -1797,7 +1661,7 @@ describe('index', () => { it('retains subfolder structure when referencing a folder', async () => { fsExtra.outputFileSync(`${rootDir}/flavors/shared/resources/images/fhd/image.jpg`, ''); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', { @@ -1817,7 +1681,7 @@ describe('index', () => { 'flavors/shared/resources/images/fhd/image.jpg', 'resources/image.jpg' ]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', { @@ -1919,7 +1783,7 @@ describe('index', () => { dest: s`renamed_test.md` }]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ rootDir: rootDir, stagingDir: stagingDir, files: [ @@ -1971,7 +1835,7 @@ describe('index', () => { dest: s`source/main.brs` }]); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ files: [ 'manifest', 'source/**/*' @@ -1982,17 +1846,17 @@ describe('index', () => { }); }); it('is resilient to file system errors', async () => { - let copy = rokuDeploy.fsExtra.copy; + let copy = fsExtra.copy; let count = 0; //mock writeFile so we can throw a few errors during the test - sinon.stub(rokuDeploy.fsExtra, 'copy').callsFake((...args) => { + sinon.stub(fsExtra, 'copy').callsFake((...args) => { count += 1; //fail a few times if (count < 5) { throw new Error('fake error thrown as part of the unit test'); } else { - return copy.apply(rokuDeploy.fsExtra, args); + return copy.apply(fsExtra, args); } }); @@ -2004,7 +1868,7 @@ describe('index', () => { fsExtra.outputFileSync(`${rootDir}/source/main.brs`, ''); - await rokuDeploy.prepublishToStaging({ + await rokuDeploy.stage({ rootDir: rootDir, stagingDir: stagingDir, files: [ @@ -2016,17 +1880,17 @@ describe('index', () => { }); it('throws underlying error after the max fs error threshold is reached', async () => { - let copy = rokuDeploy.fsExtra.copy; + let copy = fsExtra.copy; let count = 0; //mock writeFile so we can throw a few errors during the test - sinon.stub(rokuDeploy.fsExtra, 'copy').callsFake((...args) => { + sinon.stub(fsExtra, 'copy').callsFake((...args) => { count += 1; //fail a few times if (count < 15) { throw new Error('fake error thrown as part of the unit test'); } else { - return copy.apply(rokuDeploy.fsExtra, args); + return copy.apply(fsExtra, args); } }); @@ -2038,7 +1902,7 @@ describe('index', () => { fsExtra.outputFileSync(`${rootDir}/source/main.brs`, ''); await expectThrowsAsync( - rokuDeploy.prepublishToStaging({ + rokuDeploy.stage({ rootDir: rootDir, stagingDir: stagingDir, files: [ @@ -2053,35 +1917,35 @@ describe('index', () => { describe('normalizeFilesArray', () => { it('catches invalid dest entries', () => { expect(() => { - rokuDeploy['normalizeFilesArray']([{ + util['normalizeFilesArray']([{ src: 'some/path', dest: true }]); }).to.throw(); expect(() => { - rokuDeploy['normalizeFilesArray']([{ + util['normalizeFilesArray']([{ src: 'some/path', dest: false }]); }).to.throw(); expect(() => { - rokuDeploy['normalizeFilesArray']([{ + util['normalizeFilesArray']([{ src: 'some/path', dest: /asdf/gi }]); }).to.throw(); expect(() => { - rokuDeploy['normalizeFilesArray']([{ + util['normalizeFilesArray']([{ src: 'some/path', dest: {} }]); }).to.throw(); expect(() => { - rokuDeploy['normalizeFilesArray']([{ + util['normalizeFilesArray']([{ src: 'some/path', dest: [] }]); @@ -2089,7 +1953,7 @@ describe('index', () => { }); it('normalizes directory separators paths', () => { - expect(rokuDeploy['normalizeFilesArray']([{ + expect(util['normalizeFilesArray']([{ src: `long/source/path`, dest: `long/dest/path` }])).to.eql([{ @@ -2099,7 +1963,7 @@ describe('index', () => { }); it('works for simple strings', () => { - expect(rokuDeploy['normalizeFilesArray']([ + expect(util['normalizeFilesArray']([ 'manifest', 'source/main.brs' ])).to.eql([ @@ -2109,7 +1973,7 @@ describe('index', () => { }); it('works for negated strings', () => { - expect(rokuDeploy['normalizeFilesArray']([ + expect(util['normalizeFilesArray']([ '!.git' ])).to.eql([ '!.git' @@ -2117,7 +1981,7 @@ describe('index', () => { }); it('skips falsey and bogus entries', () => { - expect(rokuDeploy['normalizeFilesArray']([ + expect(util['normalizeFilesArray']([ '', 'manifest', false, @@ -2129,7 +1993,7 @@ describe('index', () => { }); it('works for {src:string} objects', () => { - expect(rokuDeploy['normalizeFilesArray']([ + expect(util['normalizeFilesArray']([ { src: 'manifest' } @@ -2140,7 +2004,7 @@ describe('index', () => { }); it('works for {src:string[]} objects', () => { - expect(rokuDeploy['normalizeFilesArray']([ + expect(util['normalizeFilesArray']([ { src: [ 'manifest', @@ -2157,7 +2021,7 @@ describe('index', () => { }); it('retains dest option', () => { - expect(rokuDeploy['normalizeFilesArray']([ + expect(util['normalizeFilesArray']([ { src: 'source/config.dev.brs', dest: 'source/config.brs' @@ -2169,96 +2033,30 @@ describe('index', () => { }); it('throws when encountering invalid entries', () => { - expect(() => rokuDeploy['normalizeFilesArray']([true])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([/asdf/])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([new Date()])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([1])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([{ src: true }])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([{ src: /asdf/ }])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([{ src: new Date() }])).to.throw(); - expect(() => rokuDeploy['normalizeFilesArray']([{ src: 1 }])).to.throw(); + expect(() => util['normalizeFilesArray']([true])).to.throw(); + expect(() => util['normalizeFilesArray']([/asdf/])).to.throw(); + expect(() => util['normalizeFilesArray']([new Date()])).to.throw(); + expect(() => util['normalizeFilesArray']([1])).to.throw(); + expect(() => util['normalizeFilesArray']([{ src: true }])).to.throw(); + expect(() => util['normalizeFilesArray']([{ src: /asdf/ }])).to.throw(); + expect(() => util['normalizeFilesArray']([{ src: new Date() }])).to.throw(); + expect(() => util['normalizeFilesArray']([{ src: 1 }])).to.throw(); }); }); - describe('deploy', () => { - it('does the whole migration', async () => { - fsExtra.outputFileSync(s`${rootDir}/manifest`, ''); + describe('deleteInstalledChannel', () => { + it('attempts to delete any installed dev channel on the device', async () => { mockDoPostRequest(); - writeFiles(rootDir, ['manifest']); - - let result = await rokuDeploy.deploy({ - rootDir: rootDir, + let result = await rokuDeploy.deleteDevChannel({ host: '1.2.3.4', password: 'password' }); expect(result).not.to.be.undefined; }); + }); - it('continues with deploy if deleteInstalledChannel fails', async () => { - sinon.stub(rokuDeploy, 'deleteInstalledChannel').returns( - Promise.reject( - new Error('failed') - ) - ); - mockDoPostRequest(); - let result = await rokuDeploy.deploy({ - host: '1.2.3.4', - password: 'password', - ...options, - //something in the previous test is locking the default output zip file. We should fix that at some point... - outDir: s`${tempDir}/test1` - }); - expect(result).not.to.be.undefined; - }); - - it('should delete installed channel if requested', async () => { - fsExtra.outputFileSync(s`${rootDir}/manifest`, ''); - - const spy = sinon.spy(rokuDeploy, 'deleteInstalledChannel'); - options.deleteInstalledChannel = true; - mockDoPostRequest(); - - await rokuDeploy.deploy({ - rootDir: rootDir, - host: '1.2.3.4', - password: 'password', - deleteInstalledChannel: true - }); - - expect(spy.called).to.equal(true); - }); - - it('should not delete installed channel if not requested', async () => { - fsExtra.outputFileSync(s`${rootDir}/manifest`, ''); - - const spy = sinon.spy(rokuDeploy, 'deleteInstalledChannel'); - mockDoPostRequest(); - - await rokuDeploy.deploy({ - rootDir: rootDir, - host: '1.2.3.4', - password: 'password', - deleteInstalledChannel: false - }); - - expect(spy.notCalled).to.equal(true); - }); - }); - - describe('deleteInstalledChannel', () => { - it('attempts to delete any installed dev channel on the device', async () => { - mockDoPostRequest(); - - let result = await rokuDeploy.deleteInstalledChannel({ - host: '1.2.3.4', - password: 'password' - }); - expect(result).not.to.be.undefined; - }); - }); - - describe('takeScreenshot', () => { + describe('captureScreenshot', () => { let onHandler: any; let screenshotAddress: any; @@ -2301,19 +2099,19 @@ describe('index', () => { `); mockDoPostRequest(body); - await expectThrowsAsync(rokuDeploy.takeScreenshot({ host: options.host, password: options.password })); + await expectThrowsAsync(rokuDeploy.captureScreenshot({ host: options.host, password: options.password })); }); it('throws when there is no response body', async () => { // missing body mockDoPostRequest(null); - await expectThrowsAsync(rokuDeploy.takeScreenshot({ host: options.host, password: options.password })); + await expectThrowsAsync(rokuDeploy.captureScreenshot({ host: options.host, password: options.password })); }); it('throws when there is an empty response body', async () => { // empty body mockDoPostRequest(); - await expectThrowsAsync(rokuDeploy.takeScreenshot({ host: options.host, password: options.password })); + await expectThrowsAsync(rokuDeploy.captureScreenshot({ host: options.host, password: options.password })); }); it('throws when there is an error downloading the image from device', async () => { @@ -2334,7 +2132,7 @@ describe('index', () => { }; mockDoPostRequest(body); - await expectThrowsAsync(rokuDeploy.takeScreenshot({ host: options.host, password: options.password })); + await expectThrowsAsync(rokuDeploy.captureScreenshot({ host: options.host, password: options.password })); }); it('handles the device returning a png', async () => { @@ -2355,7 +2153,7 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password }); expect(result).not.to.be.undefined; expect(path.extname(result)).to.equal('.png'); expect(fsExtra.existsSync(result)); @@ -2379,7 +2177,7 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password }); expect(result).not.to.be.undefined; expect(path.extname(result)).to.equal('.jpg'); expect(fsExtra.existsSync(result)); @@ -2403,7 +2201,7 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password, outDir: `${tempDir}/myScreenShots` }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password, screenshotDir: `${tempDir}/myScreenShots` }); expect(result).not.to.be.undefined; expect(util.standardizePath(`${tempDir}/myScreenShots`)).to.equal(path.dirname(result)); expect(fsExtra.existsSync(result)); @@ -2427,7 +2225,7 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password, outDir: tempDir, outFile: 'my' }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password, screenshotDir: tempDir, screenshotFile: 'my' }); expect(result).not.to.be.undefined; expect(util.standardizePath(tempDir)).to.equal(path.dirname(result)); expect(fsExtra.existsSync(path.join(tempDir, 'my.png'))); @@ -2451,7 +2249,7 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password, outDir: tempDir, outFile: 'my.jpg' }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password, screenshotDir: tempDir, screenshotFile: 'my.jpg' }); expect(result).not.to.be.undefined; expect(util.standardizePath(tempDir)).to.equal(path.dirname(result)); expect(fsExtra.existsSync(path.join(tempDir, 'my.jpg.png'))); @@ -2475,7 +2273,7 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password }); expect(result).not.to.be.undefined; expect(fsExtra.existsSync(result)); }); @@ -2498,66 +2296,22 @@ describe('index', () => { }; mockDoPostRequest(body); - let result = await rokuDeploy.takeScreenshot({ host: options.host, password: options.password, outFile: 'myFile' }); + let result = await rokuDeploy.captureScreenshot({ host: options.host, password: options.password, screenshotFile: 'myFile' }); expect(result).not.to.be.undefined; expect(path.basename(result)).to.equal('myFile.jpg'); expect(fsExtra.existsSync(result)); }); }); - describe('zipFolder', () => { + describe('makeZip', () => { //this is mainly done to hit 100% coverage, but why not ensure the errors are handled properly? :D it('rejects the promise when an error occurs', async () => { //zip path doesn't exist await assertThrowsAsync(async () => { - await rokuDeploy.zipFolder('source', '.tmp/some/zip/path/that/does/not/exist'); + await rokuDeploy['makeZip']('source', '.tmp/some/zip/path/that/does/not/exist'); }); }); - it('allows modification of file contents with callback', async () => { - writeFiles(rootDir, [ - 'components/components/Loader/Loader.brs', - 'images/splash_hd.jpg', - 'source/main.brs', - 'manifest' - ]); - const stageFolder = path.join(tempDir, 'testProject'); - fsExtra.ensureDirSync(stageFolder); - const files = [ - 'components/components/Loader/Loader.brs', - 'images/splash_hd.jpg', - 'source/main.brs', - 'manifest' - ]; - for (const file of files) { - fsExtra.copySync(path.join(options.rootDir, file), path.join(stageFolder, file)); - } - - const outputZipPath = path.join(tempDir, 'output.zip'); - const addedManifestLine = 'bs_libs_required=roku_ads_lib'; - await rokuDeploy.zipFolder(stageFolder, outputZipPath, (file, data) => { - if (file.dest === 'manifest') { - let manifestContents = data.toString(); - manifestContents += addedManifestLine; - data = Buffer.from(manifestContents, 'utf8'); - } - return data; - }); - - const data = fsExtra.readFileSync(outputZipPath); - const zip = await JSZip.loadAsync(data); - for (const file of files) { - const zipFileContents = await zip.file(file.toString()).async('string'); - const sourcePath = path.join(options.rootDir, file); - const incomingContents = fsExtra.readFileSync(sourcePath, 'utf8'); - if (file === 'manifest') { - expect(zipFileContents).to.contain(addedManifestLine); - } else { - expect(zipFileContents).to.equal(incomingContents); - } - } - }); - it('filters the folders before making the zip', async () => { const files = [ 'components/MainScene.brs', @@ -2570,7 +2324,7 @@ describe('index', () => { writeFiles(stagingDir, files); const outputZipPath = path.join(tempDir, 'output.zip'); - await rokuDeploy.zipFolder(stagingDir, outputZipPath, null, ['**/*', '!**/*.map']); + await rokuDeploy['makeZip'](stagingDir, outputZipPath, ['**/*', '!**/*.map']); const data = fsExtra.readFileSync(outputZipPath); const zip = await JSZip.loadAsync(data); @@ -2586,66 +2340,63 @@ describe('index', () => { ].sort().filter(x => !x.endsWith('.map')) ); }); - }); - describe('parseManifest', () => { - it('correctly parses valid manifest', async () => { - fsExtra.outputFileSync(`${rootDir}/manifest`, `title=AwesomeApp`); - let parsedManifest = await rokuDeploy.parseManifest(`${rootDir}/manifest`); - expect(parsedManifest.title).to.equal('AwesomeApp'); + it('should create zip in proper directory', async () => { + const outputZipPath = path.join(outDir, 'output.zip'); + await rokuDeploy['makeZip'](rootDir, outputZipPath, ['**/*', '!**/*.map']); + expectPathExists(rokuDeploy['getOutputZipFilePath']({ outDir: outDir, outFile: 'output.zip' })); }); - it('Throws our error message for a missing file', async () => { - await expectThrowsAsync( - rokuDeploy.parseManifest('invalid-path'), - `invalid-path does not exist` - ); - }); - }); + it('should only include the specified files', async () => { + await rokuDeploy.stage({ + files: [ + 'manifest' + ], + stagingDir: stagingDir, + rootDir: rootDir + }); - describe('parseManifestFromString', () => { - it('correctly parses valid manifest', () => { - let parsedManifest = rokuDeploy.parseManifestFromString(` - title=RokuDeployTestChannel - major_version=1 - minor_version=0 - build_version=0 - splash_screen_hd=pkg:/images/splash_hd.jpg - ui_resolutions=hd - bs_const=IS_DEV_BUILD=false - splash_color=#000000 - `); - expect(parsedManifest.title).to.equal('RokuDeployTestChannel'); - expect(parsedManifest.major_version).to.equal('1'); - expect(parsedManifest.minor_version).to.equal('0'); - expect(parsedManifest.build_version).to.equal('0'); - expect(parsedManifest.splash_screen_hd).to.equal('pkg:/images/splash_hd.jpg'); - expect(parsedManifest.ui_resolutions).to.equal('hd'); - expect(parsedManifest.bs_const).to.equal('IS_DEV_BUILD=false'); - expect(parsedManifest.splash_color).to.equal('#000000'); - }); - }); + await rokuDeploy.zip({ + stagingDir: stagingDir, + outDir: outDir + }); + const data = fsExtra.readFileSync(rokuDeploy['getOutputZipFilePath']({ outDir: outDir })); + const zip = await JSZip.loadAsync(data); - describe('stringifyManifest', () => { - it('correctly converts back to a valid manifest when lineNumber and keyIndexes are provided', () => { - expect( - rokuDeploy.stringifyManifest( - rokuDeploy.parseManifestFromString('major_version=3\nminor_version=4') - ) - ).to.equal( - 'major_version=3\nminor_version=4' - ); + const files = ['manifest']; + for (const file of files) { + const zipFileContents = await zip.file(file.toString()).async('string'); + const sourcePath = path.join(options.rootDir, file); + const incomingContents = fsExtra.readFileSync(sourcePath, 'utf8'); + expect(zipFileContents).to.equal(incomingContents); + } }); - it('correctly converts back to a valid manifest when lineNumber and keyIndexes are not provided', () => { - const parsed = rokuDeploy.parseManifestFromString('title=App\nmajor_version=3'); - delete parsed.keyIndexes; - delete parsed.lineCount; - let outputParsedManifest = rokuDeploy.parseManifestFromString( - rokuDeploy.stringifyManifest(parsed) - ); - expect(outputParsedManifest.title).to.equal('App'); - expect(outputParsedManifest.major_version).to.equal('3'); + it('generates full package with defaults', async () => { + const filePaths = writeFiles(rootDir, [ + 'components/components/Loader/Loader.brs', + 'images/splash_hd.jpg', + 'source/main.brs', + 'manifest' + ]); + options = { + files: filePaths, + stagingDir: stagingDir, + outDir: outDir, + rootDir: rootDir + }; + await rokuDeploy.stage(options); + await rokuDeploy.zip(options); + + const data = fsExtra.readFileSync(rokuDeploy['getOutputZipFilePath']({ outDir: outDir })); + const zip = await JSZip.loadAsync(data); + + for (const file of filePaths) { + const zipFileContents = await zip.file(file.toString())?.async('string'); + const sourcePath = path.join(options.rootDir, file); + const incomingContents = fsExtra.readFileSync(sourcePath, 'utf8'); + expect(zipFileContents).to.equal(incomingContents); + } }); }); @@ -2654,6 +2405,8 @@ describe('index', () => { const otherProjectDir = sp`${rootDir}/../${otherProjectName}`; //create baseline project structure beforeEach(() => { + rokuDeploy = new RokuDeploy(); + options = rokuDeploy.getOptions({}); fsExtra.ensureDirSync(`${rootDir}/components/emptyFolder`); writeFiles(rootDir, [ `manifest`, @@ -3138,10 +2891,8 @@ describe('index', () => { }); it('supports absolute paths from outside of the rootDir', async () => { - options = rokuDeploy.getOptions(options); - //dest not specified - expect(await rokuDeploy.getFilePaths([{ + expect(await getFilePaths([{ src: sp`${cwd}/README.md` }], options.rootDir)).to.eql([{ src: s`${cwd}/README.md`, @@ -3149,7 +2900,7 @@ describe('index', () => { }]); //dest specified - expect(await rokuDeploy.getFilePaths([{ + expect(await getFilePaths([{ src: sp`${cwd}/README.md`, dest: 'docs/README.md' }], options.rootDir)).to.eql([{ @@ -3159,7 +2910,7 @@ describe('index', () => { let paths: any[]; - paths = await rokuDeploy.getFilePaths([{ + paths = await getFilePaths([{ src: sp`${cwd}/README.md`, dest: s`docs/README.md` }], outDir); @@ -3171,7 +2922,7 @@ describe('index', () => { //top-level string paths pointing to files outside the root should thrown an exception await expectThrowsAsync(async () => { - paths = await rokuDeploy.getFilePaths([ + paths = await getFilePaths([ sp`${cwd}/README.md` ], outDir); }); @@ -3182,7 +2933,7 @@ describe('index', () => { 'README.md' ]); expect( - await rokuDeploy.getFilePaths([{ + await getFilePaths([{ src: sp`../README.md` }], rootDir) ).to.eql([{ @@ -3191,7 +2942,7 @@ describe('index', () => { }]); expect( - await rokuDeploy.getFilePaths([{ + await getFilePaths([{ src: sp`../README.md`, dest: 'docs/README.md' }], rootDir) @@ -3206,14 +2957,14 @@ describe('index', () => { '../README.md' ]); await expectThrowsAsync( - rokuDeploy.getFilePaths([ + getFilePaths([ path.posix.join('..', 'README.md') ], outDir) ); }); it('supports overriding paths', async () => { - let paths = await rokuDeploy.getFilePaths([{ + let paths = await getFilePaths([{ src: sp`${rootDir}/components/component1.brs`, dest: 'comp1.brs' }, { @@ -3245,7 +2996,7 @@ describe('index', () => { dest: 'components/MainScene.brs' } ]; - let paths = await rokuDeploy.getFilePaths(files, thisRootDir); + let paths = await getFilePaths(files, thisRootDir); //the MainScene.brs file from source should NOT be included let mainSceneEntries = paths.filter(x => s`${x.dest}` === s`components/MainScene.brs`); @@ -3263,7 +3014,7 @@ describe('index', () => { it('maintains original file path', async () => { fsExtra.outputFileSync(`${rootDir}/components/CustomButton.brs`, ''); expect( - await rokuDeploy.getFilePaths([ + await getFilePaths([ 'components/CustomButton.brs' ], rootDir) ).to.eql([{ @@ -3275,7 +3026,7 @@ describe('index', () => { it('correctly assumes file path if not given', async () => { fsExtra.outputFileSync(`${rootDir}/components/CustomButton.brs`, ''); expect( - (await rokuDeploy.getFilePaths([ + (await getFilePaths([ { src: 'components/*' } ], rootDir)).sort((a, b) => a.src.localeCompare(b.src)) ).to.eql([{ @@ -3291,231 +3042,41 @@ describe('index', () => { }); }); - describe('computeFileDestPath', () => { - it('treats {src;dest} without dest as a top-level string', () => { - expect( - rokuDeploy['computeFileDestPath'](s`${rootDir}/source/main.brs`, { src: s`source/main.brs` } as any, rootDir) - ).to.eql(s`source/main.brs`); - }); - }); - - describe('getDestPath', () => { - it('handles unrelated exclusions properly', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/components/comp1/comp1.brs`, - [ - '**/*', - '!exclude.me' - ], - rootDir - ) - ).to.equal(s`components/comp1/comp1.brs`); - }); - - it('finds dest path for top-level path', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/components/comp1/comp1.brs`, - ['components/**/*'], - rootDir - ) - ).to.equal(s`components/comp1/comp1.brs`); - }); - - it('does not find dest path for non-matched top-level path', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/source/main.brs`, - ['components/**/*'], - rootDir - ) - ).to.be.undefined; - }); - - it('excludes a file that is negated', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/source/main.brs`, - [ - 'source/**/*', - '!source/main.brs' - ], - rootDir - ) - ).to.be.undefined; - }); - - it('excludes file from non-rootdir top-level pattern', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/../externalDir/source/main.brs`, - [ - '!../externalDir/**/*' - ], - rootDir - ) - ).to.be.undefined; - }); - - it('excludes a file that is negated in src;dest;', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/source/main.brs`, - [ - 'source/**/*', - { - src: '!source/main.brs' - } - ], - rootDir - ) - ).to.be.undefined; - }); - - it('works for brighterscript files', () => { - let destPath = rokuDeploy.getDestPath( - util.standardizePath(`${cwd}/src/source/main.bs`), - [ - 'manifest', - 'source/**/*.bs' - ], - s`${cwd}/src` - ); - expect(s`${destPath}`).to.equal(s`source/main.bs`); - }); - - it('excludes a file found outside the root dir', () => { - expect( - rokuDeploy.getDestPath( - s`${rootDir}/../source/main.brs`, - [ - '../source/**/*' - ], - rootDir - ) - ).to.be.undefined; - }); - }); - - describe('normalizeRootDir', () => { - it('handles falsey values', () => { - expect(rokuDeploy.normalizeRootDir(null)).to.equal(cwd); - expect(rokuDeploy.normalizeRootDir(undefined)).to.equal(cwd); - expect(rokuDeploy.normalizeRootDir('')).to.equal(cwd); - expect(rokuDeploy.normalizeRootDir(' ')).to.equal(cwd); - expect(rokuDeploy.normalizeRootDir('\t')).to.equal(cwd); - }); - - it('handles non-falsey values', () => { - expect(rokuDeploy.normalizeRootDir(cwd)).to.equal(cwd); - expect(rokuDeploy.normalizeRootDir('./')).to.equal(cwd); - expect(rokuDeploy.normalizeRootDir('./testProject')).to.equal(path.join(cwd, 'testProject')); - }); - }); - - describe('retrieveSignedPackage', () => { - let onHandler: any; - beforeEach(() => { - sinon.stub(rokuDeploy.fsExtra, 'ensureDir').callsFake(((pth: string, callback: (err: Error) => void) => { - //do nothing, assume the dir gets created - }) as any); - - //intercept the http request - sinon.stub(request, 'get').callsFake(() => { - let req: any = { - on: (event, callback) => { - process.nextTick(() => { - onHandler(event, callback); - }); - return req; - }, - pipe: async () => { - //if a write stream gets created, write some stuff and close it - const writeStream = await writeStreamPromise; - writeStream.write('test'); - writeStream.close(); - } - }; - return req; - }); - }); - - it('returns a pkg file path on success', async () => { - onHandler = (event, callback) => { - if (event === 'response') { - callback({ - statusCode: 200 - }); - } - }; - let pkgFilePath = await rokuDeploy.retrieveSignedPackage('path_to_pkg', { - host: '1.2.3.4', - outFile: 'roku-deploy-test', - password: 'aaaa' - }); - expect(pkgFilePath).to.equal(path.join(process.cwd(), 'out', 'roku-deploy-test.pkg')); - }); - - it('returns a pkg file path on success', async () => { - //the write stream should return null, which causes a specific branch to be executed - createWriteStreamStub.callsFake(() => { - return null; - }); - - onHandler = (event, callback) => { - if (event === 'response') { - callback({ - statusCode: 200 - }); - } - }; - - let error: Error; - try { - await rokuDeploy.retrieveSignedPackage('path_to_pkg', { - host: '1.2.3.4', - password: 'password', - outFile: 'roku-deploy-test' - }); - } catch (e) { - error = e as any; - } - expect(error.message.startsWith('Unable to create write stream for')).to.be.true; + describe('parseManifest', () => { + it('correctly parses valid manifest', async () => { + fsExtra.outputFileSync(`${rootDir}/manifest`, `title=AwesomeApp`); + let parsedManifest = await rokuDeploy['parseManifest'](`${rootDir}/manifest`); + expect(parsedManifest.title).to.equal('AwesomeApp'); }); - it('throws when error in request is encountered', async () => { - onHandler = (event, callback) => { - if (event === 'error') { - callback(new Error('Some error')); - } - }; + it('Throws our error message for a missing file', async () => { await expectThrowsAsync( - rokuDeploy.retrieveSignedPackage('path_to_pkg', { - host: '1.2.3.4', - outFile: 'roku-deploy-test', - password: 'aaaa' - }), - 'Some error' + rokuDeploy['parseManifest']('invalid-path'), + `invalid-path does not exist` ); }); + }); - it('throws when status code is non 200', async () => { - onHandler = (event, callback) => { - if (event === 'response') { - callback({ - statusCode: 500 - }); - } - }; - await expectThrowsAsync( - rokuDeploy.retrieveSignedPackage('path_to_pkg', { - host: '1.2.3.4', - outFile: 'roku-deploy-test', - password: 'aaaa' - }), - 'Invalid response code: 500' - ); + describe('parseManifestFromString', () => { + it('correctly parses valid manifest', () => { + let parsedManifest = rokuDeploy['parseManifestFromString'](` + title=RokuDeployTestChannel + major_version=1 + minor_version=0 + build_version=0 + splash_screen_hd=pkg:/images/splash_hd.jpg + ui_resolutions=hd + bs_const=IS_DEV_BUILD=false + splash_color=#000000 + `); + expect(parsedManifest.title).to.equal('RokuDeployTestChannel'); + expect(parsedManifest.major_version).to.equal('1'); + expect(parsedManifest.minor_version).to.equal('0'); + expect(parsedManifest.build_version).to.equal('0'); + expect(parsedManifest.splash_screen_hd).to.equal('pkg:/images/splash_hd.jpg'); + expect(parsedManifest.ui_resolutions).to.equal('hd'); + expect(parsedManifest.bs_const).to.equal('IS_DEV_BUILD=false'); + expect(parsedManifest.splash_color).to.equal('#000000'); }); }); @@ -3538,21 +3099,6 @@ describe('index', () => { }); describe('getOptions', () => { - it('supports deprecated stagingFolderPath option', () => { - sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { - return false; - }); - expect( - rokuDeploy.getOptions({ stagingFolderPath: 'staging-folder-path' }).stagingDir - ).to.eql(s`${cwd}/staging-folder-path`); - expect( - rokuDeploy.getOptions({ stagingFolderPath: 'staging-folder-path', stagingDir: 'staging-dir' }).stagingDir - ).to.eql(s`${cwd}/staging-dir`); - expect( - rokuDeploy.getOptions({ stagingFolderPath: 'staging-folder-path' }).stagingFolderPath - ).to.eql(s`${cwd}/staging-folder-path`); - }); - it('calling with no parameters works', () => { sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { return false; @@ -3570,89 +3116,23 @@ describe('index', () => { }); it('works when passing in stagingDir', () => { - sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { - return false; - }); options = rokuDeploy.getOptions({ stagingDir: './staging-dir' }); expect(options.stagingDir.endsWith('staging-dir')).to.be.true; }); - it('works when loading stagingDir from rokudeploy.json', () => { - sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { - return true; - }); - sinon.stub(fsExtra, 'readFileSync').returns(` - { - "stagingDir": "./staging-dir" - } - `); - options = rokuDeploy.getOptions(); - expect(options.stagingDir.endsWith('staging-dir')).to.be.true; - }); - - it('supports jsonc for roku-deploy.json', () => { - sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { - return (filePath as string).endsWith('rokudeploy.json'); - }); - sinon.stub(fsExtra, 'readFileSync').returns(` - //leading comment - { - //inner comment - "rootDir": "src" //trailing comment - } - //trailing comment - `); - options = rokuDeploy.getOptions(undefined); - expect(options.rootDir).to.equal(path.join(process.cwd(), 'src')); - }); - - it('supports jsonc for bsconfig.json', () => { - sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { - return (filePath as string).endsWith('bsconfig.json'); - }); - sinon.stub(fsExtra, 'readFileSync').returns(` - //leading comment - { - //inner comment - "rootDir": "src" //trailing comment - } - //trailing comment - `); - options = rokuDeploy.getOptions(undefined); - expect(options.rootDir).to.equal(path.join(process.cwd(), 'src')); - }); - - it('catches invalid json with jsonc parser', () => { - sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { - return (filePath as string).endsWith('bsconfig.json'); - }); - sinon.stub(fsExtra, 'readFileSync').returns(` - { - "rootDir": "src" - `); - let ex; - try { - rokuDeploy.getOptions(undefined); - } catch (e) { - ex = e; - } - expect(ex).to.exist; - expect(ex.message.startsWith('Error parsing')).to.be.true; - }); - it('does not error when no parameter provided', () => { expect(rokuDeploy.getOptions(undefined)).to.exist; }); - describe('deleteInstalledChannel', () => { + describe('deleteDevChannel', () => { it('defaults to true', () => { - expect(rokuDeploy.getOptions({}).deleteInstalledChannel).to.equal(true); + expect(rokuDeploy.getOptions({}).deleteDevChannel).to.equal(true); }); it('can be overridden', () => { - expect(rokuDeploy.getOptions({ deleteInstalledChannel: false }).deleteInstalledChannel).to.equal(false); + expect(rokuDeploy.getOptions({ deleteDevChannel: false }).deleteDevChannel).to.equal(false); }); }); @@ -3678,7 +3158,7 @@ describe('index', () => { }); }); - describe('config file', () => { + describe('default options', () => { beforeEach(() => { process.chdir(rootDir); }); @@ -3687,46 +3167,17 @@ describe('index', () => { expect(rokuDeploy.getOptions().outFile).to.equal('roku-deploy'); }); - it('if rokudeploy.json config file is available it should use those values instead of the default', () => { - fsExtra.writeJsonSync(s`${rootDir}/rokudeploy.json`, { outFile: 'rokudeploy-outfile' }); - expect(rokuDeploy.getOptions().outFile).to.equal('rokudeploy-outfile'); - }); - - it('if bsconfig.json config file is available it should use those values instead of the default', () => { - fsExtra.writeJsonSync(`${rootDir}/bsconfig.json`, { outFile: 'bsconfig-outfile' }); - expect(rokuDeploy.getOptions().outFile).to.equal('bsconfig-outfile'); - }); - - it('if rokudeploy.json config file is available and bsconfig.json is also available it should use rokudeploy.json instead of bsconfig.json', () => { - fsExtra.outputJsonSync(`${rootDir}/bsconfig.json`, { outFile: 'bsconfig-outfile' }); - fsExtra.outputJsonSync(`${rootDir}/rokudeploy.json`, { outFile: 'rokudeploy-outfile' }); - expect(rokuDeploy.getOptions().outFile).to.equal('rokudeploy-outfile'); - }); - - it('if runtime options are provided, they should override any existing config file options', () => { - fsExtra.writeJsonSync(`${rootDir}/bsconfig.json`, { outFile: 'bsconfig-outfile' }); - fsExtra.writeJsonSync(`${rootDir}/rokudeploy.json`, { outFile: 'rokudeploy-outfile' }); + it('if runtime options are provided, they should override any default options', () => { expect(rokuDeploy.getOptions({ outFile: 'runtime-outfile' }).outFile).to.equal('runtime-outfile'); }); - - it('if runtime config should override any existing config file options', () => { - fsExtra.writeJsonSync(s`${rootDir}/rokudeploy.json`, { outFile: 'rokudeploy-outfile' }); - fsExtra.writeJsonSync(s`${rootDir}/bsconfig`, { outFile: 'rokudeploy-outfile' }); - - fsExtra.writeJsonSync(s`${rootDir}/brsconfig.json`, { outFile: 'project-config-outfile' }); - options = { - project: 'brsconfig.json' - }; - expect(rokuDeploy.getOptions(options).outFile).to.equal('project-config-outfile'); - }); }); }); - describe('getToFile', () => { + describe('downloadFile', () => { it('waits for the write stream to finish writing before resolving', async () => { - let getToFileIsResolved = false; + let downloadFileIsResolved = false; let requestCalled = q.defer(); let onResponse = q.defer<(res) => any>(); @@ -3748,85 +3199,25 @@ describe('index', () => { return req; }); - const finalPromise = rokuDeploy['getToFile']({}, s`${tempDir}/out/something.txt`).then(() => { - getToFileIsResolved = true; + const finalPromise = rokuDeploy['downloadFile']({}, s`${tempDir}/out/something.txt`).then(() => { + downloadFileIsResolved = true; }); await requestCalled.promise; - expect(getToFileIsResolved).to.be.false; + expect(downloadFileIsResolved).to.be.false; const callback = await onResponse.promise; callback({ statusCode: 200 }); await util.sleep(10); - expect(getToFileIsResolved).to.be.false; + expect(downloadFileIsResolved).to.be.false; const writeStream = await writeStreamPromise; writeStream.write('test'); writeStream.close(); await finalPromise; - expect(getToFileIsResolved).to.be.true; - }); - }); - - describe('deployAndSignPackage', () => { - beforeEach(() => { - //pretend the deploy worked - sinon.stub(rokuDeploy, 'deploy').returns(Promise.resolve(null)); - //pretend the sign worked - sinon.stub(rokuDeploy, 'signExistingPackage').returns(Promise.resolve(null)); - //pretend fetching the signed package worked - sinon.stub(rokuDeploy, 'retrieveSignedPackage').returns(Promise.resolve('some_local_path')); - }); - - it('succeeds and does proper things with staging folder', async () => { - let stub = sinon.stub(rokuDeploy['fsExtra'], 'remove').returns(Promise.resolve() as any); - - //this should not fail - let pkgFilePath = await rokuDeploy.deployAndSignPackage({ - host: '1.2.3.4', - password: 'password', - signingPassword: 'secret', - retainStagingDir: false - }); - - //the return value should equal what retrieveSignedPackage returned. - expect(pkgFilePath).to.equal('some_local_path'); - - //fsExtra.remove should have been called - expect(stub.getCalls()).to.be.lengthOf(1); - - //call it again, but specify true for retainStagingDir - await rokuDeploy.deployAndSignPackage({ - host: '1.2.3.4', - password: 'password', - signingPassword: 'secret', - retainStagingDir: true - }); - //call count should NOT increase - expect(stub.getCalls()).to.be.lengthOf(1); - - //call it again, but don't specify retainStagingDir at all (it should default to FALSE) - await rokuDeploy.deployAndSignPackage({ - host: '1.2.3.4', - password: 'password', - signingPassword: 'secret' - }); - //call count should NOT increase - expect(stub.getCalls()).to.be.lengthOf(2); - }); - - it('converts to squashfs if we request it to', async () => { - // options.convertToSquashfs = true; - let stub = sinon.stub(rokuDeploy, 'convertToSquashfs').returns(Promise.resolve(null)); - await rokuDeploy.deployAndSignPackage({ - host: '1.2.3.4', - password: 'password', - signingPassword: 'secret', - convertToSquashfs: true - }); - expect(stub.getCalls()).to.be.lengthOf(1); + expect(downloadFileIsResolved).to.be.true; }); }); diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index 4438f21..f874550 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -1,23 +1,20 @@ import * as path from 'path'; -import * as _fsExtra from 'fs-extra'; +import * as fsExtra from 'fs-extra'; +import type { WriteStream, ReadStream } from 'fs-extra'; 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'; -import * as isGlob from 'is-glob'; -import * as picomatch from 'picomatch'; import * as xml2js from 'xml2js'; -import type { ParseError } from 'jsonc-parser'; -import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'; +import { parse as parseJsonc } from 'jsonc-parser'; import { util } from './util'; 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'; +import * as tempDir from 'temp-dir'; export class RokuDeploy { @@ -26,90 +23,56 @@ export class RokuDeploy { } private logger: Logger; - //store the import on the class to make testing easier - - public fsExtra = _fsExtra; - - public screenshotDir = path.join(tempDir, '/roku-deploy/screenshots/'); /** * Copies all of the referenced files to the staging folder * @param options */ - public async prepublishToStaging(options: PrepublishToStagingOptions) { + public async stage(options: StageOptions) { options = this.getOptions(options) as any; //clean the staging directory - await this.fsExtra.remove(options.stagingDir); + await fsExtra.remove(options.stagingDir); //make sure the staging folder exists - await this.fsExtra.ensureDir(options.stagingDir); - await this.copyToStaging(options.files, options.stagingDir, options.rootDir); - return options.stagingDir; - } + await fsExtra.ensureDir(options.stagingDir); - /** - * Given an array of `FilesType`, normalize them each into a `StandardizedFileEntry`. - * Each entry in the array or inner `src` array will be extracted out into its own object. - * This makes it easier to reason about later on in the process. - * @param files - */ - public normalizeFilesArray(files: FileEntry[]) { - const result: Array = []; - - for (let i = 0; i < files.length; i++) { - let entry = files[i]; - //skip falsey and blank entries - if (!entry) { - continue; - - //string entries - } else if (typeof entry === 'string') { - result.push(entry); - - //objects with src: (string | string[]) - } else if ('src' in entry) { - //validate dest - if (entry.dest !== undefined && entry.dest !== null && typeof entry.dest !== 'string') { - throw new Error(`Invalid type for "dest" at index ${i} of files array`); - } + if (!options.stagingDir) { + throw new Error('stagingPath is required'); + } + if (!await fsExtra.pathExists(options.rootDir)) { + throw new Error(`rootDir does not exist at "${options.rootDir}"`); + } - //objects with src: string - if (typeof entry.src === 'string') { - result.push({ - src: entry.src, - dest: util.standardizePath(entry.dest) - }); + let fileObjects = await this.getFilePaths(options.files, options.rootDir); + //copy all of the files + await Promise.all(fileObjects.map(async (fileObject) => { + let destFilePath = util.standardizePath(`${options.stagingDir}/${fileObject.dest}`); - //objects with src:string[] - } else if ('src' in entry && Array.isArray(entry.src)) { - //create a distinct entry for each item in the src array - for (let srcEntry of entry.src) { - result.push({ - src: srcEntry, - dest: util.standardizePath(entry.dest) - }); - } - } else { - throw new Error(`Invalid type for "src" at index ${i} of files array`); - } - } else { - throw new Error(`Invalid entry at index ${i} in files array`); - } - } + //make sure the containing folder exists + await fsExtra.ensureDir(path.dirname(destFilePath)); - return result; + //sometimes the copyfile action fails due to race conditions (normally to poorly constructed src;dest; objects with duplicate files in them + await util.tryRepeatAsync(async () => { + //copy the src item using the filesystem + await fsExtra.copy(fileObject.src, destFilePath, { + //copy the actual files that symlinks point to, not the symlinks themselves + dereference: true + }); + }, 10); + })); + return options.stagingDir; } /** * Given an already-populated staging folder, create a zip archive of it and copy it to the output folder * @param options */ - public async zipPackage(options: ZipPackageOptions) { + public async zip(options: ZipOptions) { options = this.getOptions(options) as any; //make sure the output folder exists - await this.fsExtra.ensureDir(options.outDir); + await fsExtra.ensureDir(options.outDir); let zipFilePath = this.getOutputZipFilePath(options as any); @@ -119,55 +82,39 @@ export class RokuDeploy { } //create a zip of the staging folder - await this.zipFolder(options.stagingDir, zipFilePath); - - //delete the staging folder unless told to retain it. - if (options.retainStagingDir !== true) { - await this.fsExtra.remove(options.stagingDir); - } + await this.makeZip(options.stagingDir, zipFilePath); } /** - * Create a zip folder containing all of the specified roku project files. - * @param options + * Given a path to a folder, zip up that folder and all of its contents + * @param srcFolder the folder that should be zipped + * @param zipFilePath the path to the zip that will be created + * @param files a files array used to filter the files from `srcFolder` */ - public async createPackage(options: CreatePackageOptions, beforeZipCallback?: (info: BeforeZipCallbackInfo) => Promise | void) { - options = this.getOptions(options) as any; - - await this.prepublishToStaging(options); - - let manifestPath = util.standardizePath(`${options.stagingDir}/manifest`); - let parsedManifest = await this.parseManifest(manifestPath); - - if (options.incrementBuildNumber) { - let timestamp = dateformat(new Date(), 'yymmddHHMM'); - parsedManifest.build_version = timestamp; //eslint-disable-line camelcase - await this.fsExtra.writeFile(manifestPath, this.stringifyManifest(parsedManifest)); - } - - if (beforeZipCallback) { - let info: BeforeZipCallbackInfo = { - manifestData: parsedManifest, - stagingFolderPath: options.stagingDir, - stagingDir: options.stagingDir - }; + private async makeZip(srcFolder: string, zipFilePath: string, files: FileEntry[] = ['**/*']) { + const filePaths = await this.getFilePaths(files, srcFolder); - await Promise.resolve(beforeZipCallback(info)); - } - await this.zipPackage(options); - } + const zip = new JSZip(); + // Allows us to wait until all are done before we build the zip + const promises = []; + for (const file of filePaths) { + const promise = fsExtra.readFile(file.src).then((data) => { + const ext = path.extname(file.dest).toLowerCase(); + let compression = 'DEFLATE'; - /** - * Given a root directory, normalize it to a full path. - * Fall back to cwd if not specified - * @param rootDir - */ - public normalizeRootDir(rootDir: string) { - if (!rootDir || (typeof rootDir === 'string' && rootDir.trim().length === 0)) { - return process.cwd(); - } else { - return path.resolve(rootDir); + if (ext === '.jpg' || ext === '.png' || ext === '.jpeg') { + compression = 'STORE'; + } + zip.file(file.dest.replace(/[\\/]/g, '/'), data, { + compression: compression + }); + }); + promises.push(promise); } + await Promise.all(promises); + // level 2 compression seems to be the best balance between speed and file size. Speed matters more since most will be calling squashfs afterwards. + const content = await zip.generateAsync({ type: 'nodebuffer', compressionOptions: { level: 2 } }); + return fsExtra.writeFile(zipFilePath, content); } /** @@ -180,7 +127,7 @@ export class RokuDeploy { if (path.isAbsolute(rootDir) === false) { rootDir = this.getOptions({ rootDir: rootDir }).rootDir; } - const entries = this.normalizeFilesArray(files); + const entries = util.normalizeFilesArray(files); const srcPathsByIndex = await util.globAllByIndex( entries.map(x => { return typeof x === 'string' ? x : x.src; @@ -201,7 +148,7 @@ export class RokuDeploy { for (let srcPath of srcPaths) { srcPath = util.standardizePath(srcPath); - const dest = this.computeFileDestPath(srcPath, entry, rootDir); + const dest = util.computeFileDestPath(srcPath, entry, rootDir); //the last file with this `dest` will win, so just replace any existing entry with this one. result.set(dest, { src: srcPath, @@ -213,168 +160,6 @@ export class RokuDeploy { return [...result.values()]; } - /** - * Given a full path to a file, determine its dest path - * @param srcPath the absolute path to the file. This MUST be a file path, and it is not verified to exist on the filesystem - * @param files the files array - * @param rootDir the absolute path to the root dir - * @param skipMatch - skip running the minimatch process (i.e. assume the file is a match - * @returns the RELATIVE path to the dest location for the file. - */ - public getDestPath(srcPathAbsolute: string, files: FileEntry[], rootDir: string, skipMatch = false) { - srcPathAbsolute = util.standardizePath(srcPathAbsolute); - rootDir = rootDir.replace(/\\+/g, '/'); - const entries = this.normalizeFilesArray(files); - - function makeGlobAbsolute(pattern: string) { - return path.resolve( - path.posix.join( - rootDir, - //remove leading exclamation point if pattern is negated - pattern - //coerce all slashes to forward - ) - ).replace(/\\/g, '/'); - } - - let result: string; - - //add the file into every matching cache bucket - for (let entry of entries) { - const pattern = (typeof entry === 'string' ? entry : entry.src); - //filter previous paths - if (pattern.startsWith('!')) { - const keepFile = picomatch('!' + makeGlobAbsolute(pattern.replace(/^!/, ''))); - if (!keepFile(srcPathAbsolute)) { - result = undefined; - } - } else { - const keepFile = picomatch(makeGlobAbsolute(pattern)); - if (keepFile(srcPathAbsolute)) { - try { - result = this.computeFileDestPath( - srcPathAbsolute, - entry, - util.standardizePath(rootDir) - ); - } catch { - //ignore errors...the file just has no dest path - } - } - } - } - return result; - } - - /** - * Compute the `dest` path. This accounts for magic globstars in the pattern, - * as well as relative paths based on the dest. This is only used internally. - * @param src an absolute, normalized path for a file - * @param dest the `dest` entry for this file. If omitted, files will derive their paths relative to rootDir. - * @param pattern the glob pattern originally used to find this file - * @param rootDir absolute normalized path to the rootDir - */ - private computeFileDestPath(srcPath: string, entry: string | StandardizedFileEntry, rootDir: string) { - let result: string; - let globstarIdx: number; - //files under rootDir with no specified dest - if (typeof entry === 'string') { - if (util.isParentOfPath(rootDir, srcPath, false)) { - //files that are actually relative to rootDir - result = util.stringReplaceInsensitive(srcPath, rootDir, ''); - } else { - // result = util.stringReplaceInsensitive(srcPath, rootDir, ''); - throw new Error('Cannot reference a file outside of rootDir when using a top-level string. Please use a src;des; object instead'); - } - - //non-glob-pattern explicit file reference - } else if (!isGlob(entry.src.replace(/\\/g, '/'), { strict: false })) { - let isEntrySrcAbsolute = path.isAbsolute(entry.src); - let entrySrcPathAbsolute = isEntrySrcAbsolute ? entry.src : util.standardizePath(`${rootDir}/${entry.src}`); - - let isSrcChildOfRootDir = util.isParentOfPath(rootDir, entrySrcPathAbsolute, false); - - let fileNameAndExtension = path.basename(entrySrcPathAbsolute); - - //no dest - if (entry.dest === null || entry.dest === undefined) { - //no dest, absolute path or file outside of rootDir - if (isEntrySrcAbsolute || isSrcChildOfRootDir === false) { - //copy file to root of staging folder - result = fileNameAndExtension; - - //no dest, relative path, lives INSIDE rootDir - } else { - //copy relative file structure to root of staging folder - let srcPathRelative = util.stringReplaceInsensitive(entrySrcPathAbsolute, rootDir, ''); - result = srcPathRelative; - } - - //assume entry.dest is the relative path to the folder AND file if applicable - } else if (entry.dest === '') { - result = fileNameAndExtension; - } else { - result = entry.dest; - } - //has a globstar - } else if ((globstarIdx = entry.src.indexOf('**')) > -1) { - const rootGlobstarPath = path.resolve(rootDir, entry.src.substring(0, globstarIdx)) + path.sep; - const srcPathRelative = util.stringReplaceInsensitive(srcPath, rootGlobstarPath, ''); - if (entry.dest) { - result = `${entry.dest}/${srcPathRelative}`; - } else { - result = srcPathRelative; - } - - //`pattern` is some other glob magic - } else { - const fileNameAndExtension = path.basename(srcPath); - if (entry.dest) { - result = util.standardizePath(`${entry.dest}/${fileNameAndExtension}`); - } else { - result = util.stringReplaceInsensitive(srcPath, rootDir, ''); - } - } - - result = util.standardizePath( - //remove leading slashes - result.replace(/^[\/\\]+/, '') - ); - return result; - } - - /** - * Copy all of the files to the staging directory - * @param fileGlobs - * @param stagingPath - */ - private async copyToStaging(files: FileEntry[], stagingPath: string, rootDir: string) { - if (!stagingPath) { - throw new Error('stagingPath is required'); - } - if (!await this.fsExtra.pathExists(rootDir)) { - throw new Error(`rootDir does not exist at "${rootDir}"`); - } - - let fileObjects = await this.getFilePaths(files, rootDir); - //copy all of the files - await Promise.all(fileObjects.map(async (fileObject) => { - let destFilePath = util.standardizePath(`${stagingPath}/${fileObject.dest}`); - - //make sure the containing folder exists - await this.fsExtra.ensureDir(path.dirname(destFilePath)); - - //sometimes the copyfile action fails due to race conditions (normally to poorly constructed src;dest; objects with duplicate files in them - await util.tryRepeatAsync(async () => { - //copy the src item using the filesystem - await this.fsExtra.copy(fileObject.src, destFilePath, { - //copy the actual files that symlinks point to, not the symlinks themselves - dereference: true - }); - }, 10); - })); - } - private generateBaseRequestOptions(requestPath: string, options: BaseRequestOptions, formData = {} as T): requestType.OptionsWithUrl { options = this.getOptions(options) as any; let url = `http://${options.host}:${options.packagePort}/${requestPath}`; @@ -392,43 +177,89 @@ export class RokuDeploy { return baseRequestOptions; } + public async keyPress(options: KeyPressOptions) { + return this.sendKeyEvent({ + ...options, + key: options.key, + action: 'keypress' + }); + } + + public async keyUp(options: KeyUpOptions) { + return this.sendKeyEvent({ + ...options, + action: 'keyup' + }); + } + + public async keyDown(options: KeyDownOptions) { + return this.sendKeyEvent({ + ...options, + action: 'keydown' + }); + } + + public async sendText(options: SendTextOptions) { + const chars = options.text.split(''); + for (const char of chars) { + await this.sendKeyEvent({ + ...options, + key: `lit_${char}`, + action: 'keypress' + }); + } + } + /** * Simulate pressing the home button on the remote for this roku. * This makes the roku return to the home screen - * @param host - the host - * @param port - the port that should be used for the request. defaults to 8060 - * @param timeout - request timeout duration in milliseconds. defaults to 150000 */ - public async pressHomeButton(host, port?: number, timeout?: number) { - let options = this.getOptions(); - port = port ? port : options.remotePort; - timeout = timeout ? timeout : options.timeout; + private async sendKeyEvent(options: SendKeyEventOptions) { + let filledOptions = this.getOptions(options); // press the home button to return to the main screen return this.doPostRequest({ - url: `http://${host}:${port}/keypress/Home`, - timeout: timeout + url: `http://${filledOptions.host}:${filledOptions.remotePort}/${filledOptions.action}/${filledOptions.key}`, + timeout: filledOptions.timeout }, false); } + public async closeChannel(options: CloseChannelOptions) { + // TODO: After 13.0 releases, add check for ECP close-app support, and use that twice to kill instant resume if available + await this.sendKeyEvent({ + ...options, + action: 'keypress', + key: 'home' + }); + } + /** * Publish a pre-existing packaged zip file to a remote Roku. * @param options */ - public async publish(options: PublishOptions): Promise<{ message: string; results: any }> { + public async sideload(options: SideloadOptions): Promise<{ message: string; results: any }> { options = this.getOptions(options) as any; if (!options.host) { throw new errors.MissingRequiredOptionError('must specify the host for the Roku device'); } //make sure the outDir exists - await this.fsExtra.ensureDir(options.outDir); + await fsExtra.ensureDir(options.outDir); let zipFilePath = this.getOutputZipFilePath(options as any); - let readStream: _fsExtra.ReadStream; + + if (options.deleteDevChannel) { + try { + await this.deleteDevChannel(options); + } catch (e) { + // note we don't report the error; as we don't actually care that we could not deploy - it's just useless noise to log it. + } + } + + let readStream: ReadStream; try { - if ((await this.fsExtra.pathExists(zipFilePath)) === false) { - throw new Error(`Cannot publish because file does not exist at '${zipFilePath}'`); + if ((await fsExtra.pathExists(zipFilePath)) === false) { + throw new Error(`Cannot sideload because file does not exist at '${zipFilePath}'`); } - readStream = this.fsExtra.createReadStream(zipFilePath); + readStream = fsExtra.createReadStream(zipFilePath); //wait for the stream to open (no harm in doing this, and it helps solve an issue in the tests) await new Promise((resolve) => { readStream.on('open', resolve); @@ -477,7 +308,7 @@ export class RokuDeploy { } finally { //delete the zip file only if configured to do so if (options.retainDeploymentArchive === false) { - await this.fsExtra.remove(zipFilePath); + await fsExtra.remove(zipFilePath); } //try to close the read stream to prevent files becoming locked try { @@ -496,7 +327,7 @@ export class RokuDeploy { } /** - * Converts existing loaded package to squashfs for faster loading packages + * Converts the currently sideloaded dev app to squashfs for faster loading packages * @param options */ public async convertToSquashfs(options: ConvertToSquashfsOptions) { @@ -536,12 +367,12 @@ export class RokuDeploy { let requestOptions = this.generateBaseRequestOptions('plugin_inspect', options as any, { mysubmit: 'Rekey', passwd: options.signingPassword, - archive: null as _fsExtra.ReadStream + archive: null as ReadStream }); let results: HttpResponse; try { - requestOptions.formData.archive = this.fsExtra.createReadStream(rekeySignedPackagePath); + requestOptions.formData.archive = fsExtra.createReadStream(rekeySignedPackagePath); results = await this.doPostRequest(requestOptions); } finally { //ensure the stream is closed @@ -572,7 +403,7 @@ export class RokuDeploy { * Sign a pre-existing package using Roku and return path to retrieve it * @param options */ - public async signExistingPackage(options: SignExistingPackageOptions): Promise { + public async createSignedPackage(options: CreateSignedPackageOptions): Promise { options = this.getOptions(options) as any; if (!options.signingPassword) { throw new errors.MissingRequiredOptionError('Must supply signingPassword'); @@ -581,6 +412,14 @@ export class RokuDeploy { let parsedManifest = await this.parseManifest(manifestPath); let appName = parsedManifest.title + '/' + parsedManifest.major_version + '.' + parsedManifest.minor_version; + //prevent devId mismatch (if devId is specified) + if (options.devId) { + const deviceDevId = await this.getDevId(); + if (options.devId !== deviceDevId) { + throw new Error(`Package signing cancelled: provided devId '${options.devId}' does not match on-device devId '${deviceDevId}'`); + } + } + let requestOptions = this.generateBaseRequestOptions('plugin_package', options as any, { mysubmit: 'Package', pkg_time: (new Date()).getTime(), //eslint-disable-line camelcase @@ -597,25 +436,17 @@ export class RokuDeploy { let pkgSearchMatches = //.exec(results.body); if (pkgSearchMatches) { - return pkgSearchMatches[1]; + const url = pkgSearchMatches[1]; + let requestOptions2 = this.generateBaseRequestOptions(url, options); + + let pkgFilePath = this.getOutputPkgFilePath(options as any); + await this.downloadFile(requestOptions2, pkgFilePath); + return pkgFilePath; } throw new errors.UnknownDeviceResponseError('Unknown error signing package', results); } - /** - * Sign a pre-existing package using Roku and return path to retrieve it - * @param pkgPath - * @param options - */ - public async retrieveSignedPackage(pkgPath: string, options: RetrieveSignedPackageOptions): Promise { - options = this.getOptions(options) as any; - let requestOptions = this.generateBaseRequestOptions(pkgPath, options); - - let pkgFilePath = this.getOutputPkgFilePath(options as any); - return this.getToFile(requestOptions, pkgFilePath); - } - /** * Centralized function for handling POST http requests * @param params @@ -757,29 +588,11 @@ export class RokuDeploy { return result; } - /** - * Create a zip of the project, and then publish to the target Roku device - * @param options - */ - public async deploy(options?: DeployOptions, beforeZipCallback?: (info: BeforeZipCallbackInfo) => void) { - options = this.getOptions(options) as any; - await this.createPackage(options, beforeZipCallback); - if (options.deleteInstalledChannel) { - try { - await this.deleteInstalledChannel(options); - } catch (e) { - // note we don't report the error; as we don't actually care that we could not deploy - it's just useless noise to log it. - } - } - let result = await this.publish(options as any); - return result; - } - /** * Deletes any installed dev channel on the target Roku device * @param options */ - public async deleteInstalledChannel(options?: DeleteInstalledChannelOptions) { + public async deleteDevChannel(options?: DeleteDevChannelOptions) { options = this.getOptions(options) as any; let deleteOptions = this.generateBaseRequestOptions('plugin_install', options as any); @@ -793,9 +606,9 @@ export class RokuDeploy { /** * Gets a screenshot from the device. A side-loaded channel must be running or an error will be thrown. */ - public async takeScreenshot(options: TakeScreenshotOptions) { - options.outDir = options.outDir ?? this.screenshotDir; - options.outFile = options.outFile ?? `screenshot-${dayjs().format('YYYY-MM-DD-HH.mm.ss.SSS')}`; + public async captureScreenshot(options: CaptureScreenshotOptions) { + options = this.getOptions(options); + options.screenshotFile = options.screenshotFile ?? `screenshot-${dayjs().format('YYYY-MM-DD-HH.mm.ss.SSS')}`; let saveFilePath: string; // Ask for the device to make an image @@ -811,22 +624,22 @@ export class RokuDeploy { const [_, imageUrlOnDevice, imageExt] = /["'](pkgs\/dev(\.jpg|\.png)\?.+?)['"]/gi.exec(createScreenshotResult.body) ?? []; if (imageUrlOnDevice) { - saveFilePath = util.standardizePath(path.join(options.outDir, options.outFile + imageExt)); - await this.getToFile( + saveFilePath = util.standardizePath(path.join(options.screenshotDir, options.screenshotFile + imageExt)); + await this.downloadFile( this.generateBaseRequestOptions(imageUrlOnDevice, options), saveFilePath ); } else { - throw new Error('No screen shot url returned from device'); + throw new Error('No screenshot url returned from device'); } return saveFilePath; } - private async getToFile(requestParams: any, filePath: string) { - let writeStream: _fsExtra.WriteStream; - await this.fsExtra.ensureFile(filePath); + private async downloadFile(requestParams: any, filePath: string) { + let writeStream: WriteStream; + await fsExtra.ensureFile(filePath); return new Promise((resolve, reject) => { - writeStream = this.fsExtra.createWriteStream(filePath, { + writeStream = fsExtra.createWriteStream(filePath, { flags: 'w' }); if (!writeStream) { @@ -858,108 +671,52 @@ export class RokuDeploy { } /** - * executes sames steps as deploy and signs the package and stores it in the out folder - * @param options - */ - public async deployAndSignPackage(options?: DeployAndSignPackageOptions, beforeZipCallback?: (info: BeforeZipCallbackInfo) => void): Promise { - options = this.getOptions(options) as any; - let retainStagingDirInitialValue = options.retainStagingDir; - options.retainStagingDir = true; - await this.deploy(options as any, beforeZipCallback); - - if (options.convertToSquashfs) { - await this.convertToSquashfs(options as any); - } - - let remotePkgPath = await this.signExistingPackage(options as any); - let localPkgFilePath = await this.retrieveSignedPackage(remotePkgPath, options as any); - if (retainStagingDirInitialValue !== true) { - await this.fsExtra.remove(options.stagingDir); - } - return localPkgFilePath; - } - - /** - * Get an options with all overridden vaues, and then defaults for missing values + * Get an options with all overridden values, and then defaults for missing values * @param options */ - public getOptions(options: RokuDeployOptions = {}) { - let fileOptions: RokuDeployOptions = {}; - const fileNames = ['rokudeploy.json', 'bsconfig.json']; - if (options.project) { - fileNames.unshift(options.project); - } - - for (const fileName of fileNames) { - if (this.fsExtra.existsSync(fileName)) { - let configFileText = this.fsExtra.readFileSync(fileName).toString(); - let parseErrors = [] as ParseError[]; - fileOptions = parseJsonc(configFileText, parseErrors, { - allowEmptyContent: true, - allowTrailingComma: true, - disallowComments: false - }); - if (parseErrors.length > 0) { - throw new Error(`Error parsing "${path.resolve(fileName)}": ` + JSON.stringify( - parseErrors.map(x => { - return { - message: printParseErrorCode(x.error), - offset: x.offset, - length: x.length - }; - }) - )); - } - break; - } - } - - let defaultOptions = { + public getOptions(options: T & RokuDeployOptions = {} as any): RokuDeployOptions & T { + // Fill in default options for any missing values + options = { + cwd: process.cwd(), outDir: './out', outFile: 'roku-deploy', retainDeploymentArchive: true, - incrementBuildNumber: false, failOnCompileError: true, - deleteInstalledChannel: true, + deleteDevChannel: true, packagePort: 80, remotePort: 8060, timeout: 150000, rootDir: './', files: [...DefaultFiles], username: 'rokudev', - logLevel: LogLevel.log + logLevel: LogLevel.log, + screenshotDir: path.join(tempDir, '/roku-deploy/screenshots/'), + ...options }; - - //override the defaults with any found or provided options - let finalOptions = { ...defaultOptions, ...fileOptions, ...options }; - this.logger.logLevel = finalOptions.logLevel; + this.logger.logLevel = options.logLevel; //TODO: Handle logging differently //fully resolve the folder paths - finalOptions.rootDir = path.resolve(process.cwd(), finalOptions.rootDir); - finalOptions.outDir = path.resolve(process.cwd(), finalOptions.outDir); - - let stagingDir = finalOptions.stagingDir || finalOptions.stagingFolderPath; + options.rootDir = path.resolve(options.cwd, options.rootDir); + options.outDir = path.resolve(options.cwd, options.outDir); //stagingDir - if (stagingDir) { - finalOptions.stagingDir = path.resolve(process.cwd(), stagingDir); + if (options.stagingDir) { + options.stagingDir = path.resolve(options.cwd, options.stagingDir); } else { - finalOptions.stagingDir = path.resolve( - process.cwd(), - util.standardizePath(`${finalOptions.outDir}/.roku-deploy-staging`) + options.stagingDir = path.resolve( + options.cwd, + util.standardizePath(`${options.outDir}/.roku-deploy-staging`) ); } - //sync the new option with the old one (for back-compat) - finalOptions.stagingFolderPath = finalOptions.stagingDir; - return finalOptions; + return options; } /** * Centralizes getting output zip file path based on passed in options * @param options */ - public getOutputZipFilePath(options?: GetOutputZipFilePathOptions) { + private getOutputZipFilePath(options?: GetOutputZipFilePathOptions) { options = this.getOptions(options) as any; let zipFileName = options.outFile; @@ -976,7 +733,7 @@ export class RokuDeploy { * Centralizes getting output pkg file path based on passed in options * @param options */ - public getOutputPkgFilePath(options?: GetOutputPkgFilePathOptions) { + private getOutputPkgFilePath(options?: GetOutputPkgFilePathOptions) { options = this.getOptions(options) as any; let pkgFileName = options.outFile; @@ -1045,7 +802,7 @@ export class RokuDeploy { * decoding HtmlEntities, etc. * @param deviceInfo */ - public normalizeDeviceInfoFieldValue(value: any) { + private normalizeDeviceInfoFieldValue(value: any) { let num: number; // convert 'true' and 'false' string values to boolean if (value === 'true') { @@ -1059,21 +816,26 @@ export class RokuDeploy { } } + /** + * Get the developer ID from the device-info response + * @param options + * @returns + */ public async getDevId(options?: GetDevIdOptions) { const deviceInfo = await this.getDeviceInfo(options); return deviceInfo['keyed-developer-id']; } - public async parseManifest(manifestPath: string): Promise { - if (!await this.fsExtra.pathExists(manifestPath)) { + private async parseManifest(manifestPath: string): Promise { + if (!await fsExtra.pathExists(manifestPath)) { throw new Error(manifestPath + ' does not exist'); } - let manifestContents = await this.fsExtra.readFile(manifestPath, 'utf-8'); + let manifestContents = await fsExtra.readFile(manifestPath, 'utf-8'); return this.parseManifestFromString(manifestContents); } - public parseManifestFromString(manifestContents: string): ManifestData { + private parseManifestFromString(manifestContents: string): ManifestData { let manifestLines = manifestContents.split('\n'); let manifestData: ManifestData = {}; manifestData.keyIndexes = {}; @@ -1089,67 +851,6 @@ export class RokuDeploy { return manifestData; } - - public stringifyManifest(manifestData: ManifestData): string { - let output = []; - - if (manifestData.keyIndexes && manifestData.lineCount) { - output.fill('', 0, manifestData.lineCount); - - let key; - for (key in manifestData) { - if (key === 'lineCount' || key === 'keyIndexes') { - continue; - } - - let index = manifestData.keyIndexes[key]; - output[index] = `${key}=${manifestData[key]}`; - } - } else { - output = Object.keys(manifestData).map((key) => { - return `${key}=${manifestData[key]}`; - }); - } - - return output.join('\n'); - } - - /** - * Given a path to a folder, zip up that folder and all of its contents - * @param srcFolder the folder that should be zipped - * @param zipFilePath the path to the zip that will be created - * @param preZipCallback a function to call right before every file gets added to the zip - * @param files a files array used to filter the files from `srcFolder` - */ - public async zipFolder(srcFolder: string, zipFilePath: string, preFileZipCallback?: (file: StandardizedFileEntry, data: Buffer) => Buffer, files: FileEntry[] = ['**/*']) { - const filePaths = await this.getFilePaths(files, srcFolder); - - const zip = new JSZip(); - // Allows us to wait until all are done before we build the zip - const promises = []; - for (const file of filePaths) { - const promise = this.fsExtra.readFile(file.src).then((data) => { - if (preFileZipCallback) { - data = preFileZipCallback(file, data); - } - - const ext = path.extname(file.dest).toLowerCase(); - let compression = 'DEFLATE'; - - if (ext === '.jpg' || ext === '.png' || ext === '.jpeg') { - compression = 'STORE'; - } - zip.file(file.dest.replace(/[\\/]/g, '/'), data, { - compression: compression - }); - }); - promises.push(promise); - } - await Promise.all(promises); - // level 2 compression seems to be the best balance between speed and file size. Speed matters more since most will be calling squashfs afterwards. - const content = await zip.generateAsync({ type: 'nodebuffer', compressionOptions: { level: 2 } }); - return this.fsExtra.writeFile(zipFilePath, content); - } } export interface ManifestData { @@ -1158,21 +859,6 @@ export interface ManifestData { lineCount?: number; } -export interface BeforeZipCallbackInfo { - /** - * Contains an associative array of the parsed values in the manifest - */ - manifestData: ManifestData; - /** - * @deprecated since 3.9.0. use `stagingDir` instead - */ - stagingFolderPath: string; - /** - * The directory where the files were staged - */ - stagingDir: string; -} - export interface StandardizedFileEntry { /** * The full path to the source file @@ -1208,7 +894,7 @@ export interface HttpResponse { body: any; } -export interface TakeScreenshotOptions { +export interface CaptureScreenshotOptions { /** * The IP address or hostname of the target Roku device. * @example '192.168.1.21' @@ -1224,13 +910,13 @@ export interface TakeScreenshotOptions { * A full path to the folder where the screenshots should be saved. * Will use the OS temp directory by default */ - outDir?: string; + screenshotDir?: string; /** * The base filename the image file should be given (excluding the extension) * The default format looks something like this: screenshot-YYYY-MM-DD-HH.mm.ss.SSS. */ - outFile?: string; + screenshotFile?: string; } export interface GetDeviceInfoOptions { @@ -1253,29 +939,54 @@ export interface GetDeviceInfoOptions { enhance?: boolean; } -export interface PrepublishToStagingOptions { - rootDir?: string; - files?: FileEntry[]; - stagingDir?: string; - retainStagingDir?: boolean; +type RokuKey = 'home' | 'rev' | 'fwd' | 'play' | 'select' | 'left' | 'right' | 'down' | 'up' | 'back' | 'instantreplay' | 'info' | 'backspace' | 'search' | 'enter' | 'findremote' | 'volumeup' | 'volumedown' | 'volumemute' | 'poweroff' | 'channelup' | 'channeldown' | 'inputtuner' | 'inputhdmi1' | 'inputhdmi2' | 'inputhdmi3' | 'inputhdmi4' | 'inputav1'; +export interface SendKeyEventOptions { + action?: 'keypress' | 'keydown' | 'keyup'; + host: string; + key: RokuKey | string; + remotePort?: number; + timeout?: number; } -export interface ZipPackageOptions { - stagingDir?: string; - retainStagingDir?: boolean; - outDir?: string; +export interface KeyUpOptions extends SendKeyEventOptions { + action?: 'keyup'; + key: RokuKey; } -export interface CreatePackageOptions { +export interface KeyDownOptions extends SendKeyEventOptions { + action?: 'keydown'; + key: RokuKey; +} + +export interface KeyPressOptions extends SendKeyEventOptions { + action?: 'keypress'; + key: RokuKey; +} + +export interface SendTextOptions extends SendKeyEventOptions { + action?: 'keypress'; + text: string; +} + +export interface CloseChannelOptions { + host: string; + remotePort?: number; + timeout?: number; + +} +export interface StageOptions { rootDir?: string; files?: FileEntry[]; stagingDir?: string; - retainStagingDir?: boolean; +} + +export interface ZipOptions { + stagingDir?: string; outDir?: string; - incrementBuildNumber?: boolean; + outFile?: string; } -export interface PublishOptions { +export interface SideloadOptions { host: string; password: string; remoteDebug?: boolean; @@ -1284,6 +995,7 @@ export interface PublishOptions { retainDeploymentArchive?: boolean; outDir?: string; outFile?: string; + deleteDevChannel?: boolean; } export interface BaseRequestOptions { @@ -1308,23 +1020,19 @@ export interface RekeyDeviceOptions { devId: string; } -export interface SignExistingPackageOptions { +export interface CreateSignedPackageOptions { host: string; password: string; signingPassword: string; stagingDir?: string; -} - -export interface RetrieveSignedPackageOptions { - host: string; - password: string; - packagePort?: number; - timeout?: number; - username?: string; outDir?: string; - outFile?: string; + /** + * If specified, signing will fail if the device's devId is different than this value + */ + devId?: string; } -export interface DeleteInstalledChannelOptions { + +export interface DeleteDevChannelOptions { host: string; password: string; } @@ -1340,21 +1048,11 @@ export interface DeployOptions { files?: FileEntry[]; rootDir?: string; stagingDir?: string; - deleteInstalledChannel?: boolean; + deleteDevChannel?: boolean; outFile?: string; outDir?: string; } -export interface DeployAndSignPackageOptions { - host: string; - password: string; - signingPassword: string; - rootDir?: string; - files?: FileEntry[]; - retainStagingDir?: boolean; - convertToSquashfs?: boolean; - stagingDir?: string; -} export interface GetOutputPkgFilePathOptions { outFile?: string; outDir?: string; @@ -1371,3 +1069,6 @@ export interface GetDevIdOptions { */ timeout?: number; } + +//create a new static instance of RokuDeploy, and export those functions for backwards compatibility +export const rokuDeploy = new RokuDeploy(); diff --git a/src/RokuDeployOptions.ts b/src/RokuDeployOptions.ts index b05a5b4..058b22b 100644 --- a/src/RokuDeployOptions.ts +++ b/src/RokuDeployOptions.ts @@ -1,6 +1,11 @@ import type { LogLevel } from './Logger'; export interface RokuDeployOptions { + /** + * The working directory where the command should be executed + */ + cwd?: string; + /** * Path to a bsconfig.json project file */ @@ -37,24 +42,12 @@ export interface RokuDeployOptions { */ files?: FileEntry[]; - /** - * Set this to true to prevent the staging folder from being deleted after creating the package - * @default false - */ - retainStagingDir?: boolean; - /** * Should the zipped package be retained after deploying to a roku. If false, this will delete the zip after a deployment. * @default true */ retainDeploymentArchive?: boolean; - /** - * The path where roku-deploy should stage all of the files right before being zipped. defaults to ${outDir}/.roku-deploy-staging - * @deprecated since 3.9.0. use `stagingDir` instead - */ - stagingFolderPath?: string; - /** * The path where roku-deploy should stage all of the files right before being zipped. defaults to ${outDir}/.roku-deploy-staging */ @@ -126,16 +119,6 @@ export interface RokuDeployOptions { */ devId?: string; - /** - * If true we increment the build number to be a timestamp in the format yymmddHHMM - */ - incrementBuildNumber?: boolean; - - /** - * If true we convert to squashfs before creating the pkg file - */ - convertToSquashfs?: boolean; - /** * If true, the publish will fail on compile error */ @@ -150,7 +133,7 @@ export interface RokuDeployOptions { /** * If true, the previously installed dev channel will be deleted before installing the new one */ - deleteInstalledChannel?: boolean; + deleteDevChannel?: boolean; } export type FileEntry = (string | { src: string | string[]; dest?: string }); diff --git a/src/cli.spec.ts b/src/cli.spec.ts index 4cf25f3..c266068 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -2,19 +2,17 @@ import * as childProcess from 'child_process'; import { cwd, expectPathExists, rootDir, stagingDir, tempDir, outDir } from './testUtils.spec'; import * as fsExtra from 'fs-extra'; import { expect } from 'chai'; -import * as path from 'path'; import { createSandbox } from 'sinon'; import { rokuDeploy } from './index'; -import { PublishCommand } from './commands/PublishCommand'; +import { SideloadCommand } from './commands/SideloadCommand'; import { ConvertToSquashfsCommand } from './commands/ConvertToSquashfsCommand'; import { RekeyDeviceCommand } from './commands/RekeyDeviceCommand'; -import { SignExistingPackageCommand } from './commands/SignExistingPackageCommand'; -import { DeployCommand } from './commands/DeployCommand'; -import { DeleteInstalledChannelCommand } from './commands/DeleteInstalledChannelCommand'; -import { TakeScreenshotCommand } from './commands/TakeScreenshotCommand'; +import { CreateSignedPackageCommand } from './commands/CreateSignedPackageCommand'; +import { DeleteDevChannelCommand } from './commands/DeleteDevChannelCommand'; +import { CaptureScreenshotCommand } from './commands/CaptureScreenshotCommand'; import { GetDeviceInfoCommand } from './commands/GetDeviceInfoCommand'; import { GetDevIdCommand } from './commands/GetDevIdCommand'; -import { RetrieveSignedPackageCommand } from './commands/RetrieveSignedPackageCommand'; +import { ExecCommand } from './commands/ExecCommand'; const sinon = createSandbox(); @@ -39,44 +37,37 @@ describe('cli', () => { sinon.restore(); }); - it('Successfully runs prepublishToStaging', () => { + it('Successfully bundles an app', () => { + execSync(`node ${cwd}/dist/cli.js bundle --rootDir ${rootDir} --outDir ${outDir}`); + expectPathExists(`${outDir}/roku-deploy.zip`); + }); + + it('Successfully runs stage', () => { //make the files fsExtra.outputFileSync(`${rootDir}/source/main.brs`, ''); expect(() => { - execSync(`node ${cwd}/dist/cli.js prepublishToStaging --stagingDir ${stagingDir} --rootDir ${rootDir}`); + execSync(`node ${cwd}/dist/cli.js stage --stagingDir ${stagingDir} --rootDir ${rootDir}`); }).to.not.throw(); }); it('Successfully copies rootDir folder to staging folder', () => { fsExtra.outputFileSync(`${rootDir}/source/main.brs`, ''); - execSync(`node ${cwd}/dist/cli.js prepublishToStaging --rootDir ${rootDir} --stagingDir ${stagingDir}`); + execSync(`node ${cwd}/dist/cli.js stage --rootDir ${rootDir} --stagingDir ${stagingDir}`); expectPathExists(`${stagingDir}/source/main.brs`); }); - it('Successfully uses zipPackage to create .zip', () => { - fsExtra.outputFileSync(`${stagingDir}/manifest`, ''); - - execSync(`node ${cwd}/dist/cli.js zipPackage --stagingDir ${stagingDir} --outDir ${outDir}`); - expectPathExists(`${outDir}/roku-deploy.zip`); - }); - - it('Successfully uses createPackage to create .pkg', () => { - execSync(`node ${cwd}/dist/cli.js createPackage --stagingDir ${stagingDir} --rootDir ${rootDir} --outDir ${outDir}`); - expectPathExists(`${outDir}/roku-deploy.zip`); - }); - it('Publish passes proper options', async () => { - const stub = sinon.stub(rokuDeploy, 'publish').callsFake(async () => { + const stub = sinon.stub(rokuDeploy, 'sideload').callsFake(async () => { return Promise.resolve({ message: 'Publish successful', results: {} }); }); - const command = new PublishCommand(); + const command = new SideloadCommand(); await command.run({ host: '1.2.3.4', password: '5536', @@ -141,11 +132,11 @@ describe('cli', () => { }); it('Signs an existing package', async () => { - const stub = sinon.stub(rokuDeploy, 'signExistingPackage').callsFake(async () => { + const stub = sinon.stub(rokuDeploy, 'createSignedPackage').callsFake(async () => { return Promise.resolve(''); }); - const command = new SignExistingPackageCommand(); + const command = new CreateSignedPackageCommand(); await command.run({ host: '1.2.3.4', password: '5536', @@ -163,58 +154,12 @@ describe('cli', () => { }); }); - it('Retrieves a signed package', async () => { - const stub = sinon.stub(rokuDeploy, 'retrieveSignedPackage').callsFake(async () => { - return Promise.resolve(''); - }); - - const command = new RetrieveSignedPackageCommand(); - await command.run({ - pathToPkg: 'path_to_pkg', - host: '1.2.3.4', - password: '5536', - outFile: 'roku-deploy-test' - }); - - expect( - stub.getCall(0).args - ).to.eql(['path_to_pkg', { - host: '1.2.3.4', - password: '5536', - outFile: 'roku-deploy-test' - }]); - }); - - it('Deploys a package', async () => { - const stub = sinon.stub(rokuDeploy, 'deploy').callsFake(async () => { - return Promise.resolve({ - message: 'Convert successful', - results: {} - }); - }); - - const command = new DeployCommand(); - await command.run({ - host: '1.2.3.4', - password: '5536', - rootDir: rootDir - }); - - expect( - stub.getCall(0).args[0] - ).to.eql({ - host: '1.2.3.4', - password: '5536', - rootDir: rootDir - }); - }); - it('Deletes an installed channel', async () => { - const stub = sinon.stub(rokuDeploy, 'deleteInstalledChannel').callsFake(async () => { + const stub = sinon.stub(rokuDeploy, 'deleteDevChannel').callsFake(async () => { return Promise.resolve({ response: {}, body: {} }); }); - const command = new DeleteInstalledChannelCommand(); + const command = new DeleteDevChannelCommand(); await command.run({ host: '1.2.3.4', password: '5536' @@ -229,11 +174,11 @@ describe('cli', () => { }); it('Takes a screenshot', async () => { - const stub = sinon.stub(rokuDeploy, 'takeScreenshot').callsFake(async () => { + const stub = sinon.stub(rokuDeploy, 'captureScreenshot').callsFake(async () => { return Promise.resolve(''); }); - const command = new TakeScreenshotCommand(); + const command = new CaptureScreenshotCommand(); await command.run({ host: '1.2.3.4', password: '5536' @@ -247,18 +192,6 @@ describe('cli', () => { }); }); - it('Gets output zip file path', () => { - let zipFilePath = execSync(`node ${cwd}/dist/cli.js getOutputZipFilePath --outFile "roku-deploy" --outDir ${outDir}`).toString(); - - expect(zipFilePath.trim()).to.equal(path.join(path.resolve(outDir), 'roku-deploy.zip')); - }); - - it('Gets output pkg file path', () => { - let pkgFilePath = execSync(`node ${cwd}/dist/cli.js getOutputPkgFilePath --outFile "roku-deploy" --outDir ${outDir}`).toString(); - - expect(pkgFilePath.trim()).to.equal(path.join(path.resolve(outDir), 'roku-deploy.pkg')); - }); - it('Device info arguments are correct', async () => { const stub = sinon.stub(rokuDeploy, 'getDeviceInfo').callsFake(async () => { return Promise.resolve({ @@ -319,13 +252,118 @@ describe('cli', () => { expect( stub.getCall(0).args[0] ).to.eql({ - host: '1.2.3.4' + host: '1.2.3.4', + password: '5536' }); }); it('Zips a folder', () => { - execSync(`node ${cwd}/dist/cli.js zipFolder --srcFolder ${rootDir} --zipFilePath "roku-deploy.zip"`); + execSync(`node ${cwd}/dist/cli.js zip --stagingDir ${rootDir} --outDir ${outDir}`); + + expectPathExists(`${outDir}/roku-deploy.zip`); + }); +}); + +describe('ExecCommand', () => { + beforeEach(() => { + fsExtra.emptyDirSync(tempDir); + //most tests depend on a manifest file existing, so write an empty one + fsExtra.outputFileSync(`${rootDir}/manifest`, ''); + sinon.restore(); + }); + afterEach(() => { + fsExtra.removeSync(tempDir); + sinon.restore(); + }); + function mockDoPostRequest(body = '', statusCode = 200) { + return sinon.stub(rokuDeploy as any, 'doPostRequest').callsFake((params) => { + let results = { response: { statusCode: statusCode }, body: body }; + rokuDeploy['checkRequest'](results); + return Promise.resolve(results); + }); + } + + it('does the whole migration', async () => { + const mock = mockDoPostRequest(); + + const options = { + host: '1.2.3.4', + password: 'abcd', + rootDir: rootDir, + stagingDir: stagingDir, + outDir: outDir + }; + await new ExecCommand('stage|zip|close|sideload', options).run(); + + expect(mock.getCall(2).args[0].url).to.equal('http://1.2.3.4:80/plugin_install'); + expectPathExists(`${outDir}/roku-deploy.zip`); + }); + + it('continues with deploy if deleteDevChannel fails', async () => { + sinon.stub(rokuDeploy, 'deleteDevChannel').returns( + Promise.reject( + new Error('failed') + ) + ); + const mock = mockDoPostRequest(); + const options = { + host: '1.2.3.4', + password: 'abcd', + rootDir: rootDir, + stagingDir: stagingDir, + outDir: outDir + }; + await new ExecCommand('stage|zip|close|sideload', options).run(); + expect(mock.getCall(0).args[0].url).to.equal('http://1.2.3.4:8060/keypress/home'); + expectPathExists(`${outDir}/roku-deploy.zip`); + }); + + it('should delete installed channel if requested', async () => { + const spy = sinon.spy(rokuDeploy, 'deleteDevChannel'); + mockDoPostRequest(); + const options = { + host: '1.2.3.4', + password: 'abcd', + rootDir: rootDir, + stagingDir: stagingDir, + outDir: outDir, + deleteDevChannel: true + }; + + await new ExecCommand('stage|zip|close|sideload', options).run(); + expect(spy.called).to.equal(true); + }); + + it('should not delete installed channel if not requested', async () => { + const spy = sinon.spy(rokuDeploy, 'deleteDevChannel'); + mockDoPostRequest(); + + const options = { + host: '1.2.3.4', + password: 'abcd', + rootDir: rootDir, + stagingDir: stagingDir, + outDir: outDir, + deleteDevChannel: false + }; + + await new ExecCommand('stage|zip|close|sideload', options).run(); + expect(spy.notCalled).to.equal(true); + }); + + it('converts to squashfs if we request it to', async () => { + let stub = sinon.stub(rokuDeploy, 'convertToSquashfs').returns(Promise.resolve(null)); + mockDoPostRequest(); + const options = { + host: '1.2.3.4', + password: 'abcd', + rootDir: rootDir, + stagingDir: stagingDir, + outDir: outDir, + deleteDevChannel: false + }; - expectPathExists(`${tempDir}/roku-deploy.zip`); + await new ExecCommand('close|stage|zip|close|sideload|squash', options).run(); + expect(stub.getCalls()).to.be.lengthOf(1); }); }); diff --git a/src/cli.ts b/src/cli.ts index 002551f..dc78e02 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,160 +1,235 @@ #!/usr/bin/env node import * as yargs from 'yargs'; -import { PrepublishCommand } from './commands/PrepublishCommand'; -import { ZipPackageCommand } from './commands/ZipPackageCommand'; -import { CreatePackageCommand } from './commands/CreatePackageCommand'; -import { PublishCommand } from './commands/PublishCommand'; +import { ExecCommand } from './commands/ExecCommand'; +import { SendTextCommand } from './commands/SendTextCommand'; +import { StageCommand } from './commands/StageCommand'; +import { SideloadCommand } from './commands/SideloadCommand'; import { ConvertToSquashfsCommand } from './commands/ConvertToSquashfsCommand'; import { RekeyDeviceCommand } from './commands/RekeyDeviceCommand'; -import { SignExistingPackageCommand } from './commands/SignExistingPackageCommand'; -import { RetrieveSignedPackageCommand } from './commands/RetrieveSignedPackageCommand'; -import { DeployCommand } from './commands/DeployCommand'; -import { DeleteInstalledChannelCommand } from './commands/DeleteInstalledChannelCommand'; -import { TakeScreenshotCommand } from './commands/TakeScreenshotCommand'; -import { GetOutputZipFilePathCommand } from './commands/GetOutputZipFilePathCommand'; -import { GetOutputPkgFilePathCommand } from './commands/GetOutputPkgFilePathCommand'; +import { CreateSignedPackageCommand } from './commands/CreateSignedPackageCommand'; +import { DeleteDevChannelCommand } from './commands/DeleteDevChannelCommand'; +import { CaptureScreenshotCommand } from './commands/CaptureScreenshotCommand'; import { GetDeviceInfoCommand } from './commands/GetDeviceInfoCommand'; import { GetDevIdCommand } from './commands/GetDevIdCommand'; -import { ZipFolderCommand } from './commands/ZipFolderCommand'; +import { ZipCommand } from './commands/ZipCommand'; +import { KeyPressCommand } from './commands/KeyPressCommand'; +import { KeyUpCommand } from './commands/KeyUpCommand'; +import { KeyDownCommand } from './commands/KeyDownCommand'; void yargs - .command('prepublishToStaging', 'Copies all of the referenced files to the staging folder', (builder) => { + + .command('bundle', 'execute build actions for bundling app', (builder) => { return builder - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) - .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }); + .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) + .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) + .option('outFile', { type: 'string', description: 'The output file', demandOption: false }); }, (args: any) => { - return new PrepublishCommand().run(args); + return new ExecCommand( + 'stage|zip', + args + ).run(); }) - .command('zipPackage', 'Given an already-populated staging folder, create a zip archive of it and copy it to the output folder', (builder) => { + .command('deploy', 'execute build actions for deploying app', (builder) => { return builder - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }); + .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) + .option('outDir', { type: 'number', description: 'The output directory', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('remoteport', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }) + .option('remoteDebug', { type: 'boolean', description: 'Should the command be run in remote debug mode', demandOption: false }) + .option('remoteDebugConnectEarly', { type: 'boolean', description: 'Should the command connect to the debugger early', demandOption: false }) + .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) + .option('retainDeploymentArchive', { type: 'boolean', description: 'Should the deployment archive be retained', demandOption: false }) + .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) + .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) + .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }); }, (args: any) => { - return new ZipPackageCommand().run(args); + return new ExecCommand( + 'stage|zip|close|sideload', + args + ).run(); }) - .command('createPackage', 'Create a zip folder containing all of the specified roku project files', (builder) => { + .command('package', 'execute build actions for packaging app', (builder) => { return builder - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }); + .option('outDir', { type: 'number', description: 'The output directory', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('remoteport', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }) + .option('remoteDebug', { type: 'boolean', description: 'Should the command be run in remote debug mode', demandOption: false }) + .option('remoteDebugConnectEarly', { type: 'boolean', description: 'Should the command connect to the debugger early', demandOption: false }) + .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) + .option('retainDeploymentArchive', { type: 'boolean', description: 'Should the deployment archive be retained', demandOption: false }) + .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) + .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) + .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }) + .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) + .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }); }, (args: any) => { - return new CreatePackageCommand().run(args); + return new ExecCommand( + 'close|rekey|stage|zip|close|sideload|squash|sign', + args + ).run(); }) - .command('publish', 'Publish a pre-existing packaged zip file to a remote Roku', (builder) => { + .command('exec', 'larger command for handling a series of smaller commands', (builder) => { return builder + .option('actions', { type: 'string', description: 'The actions to be executed, separated by |', demandOption: true }) .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }); + .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) //TODO finish this. Are all of these necessary? + .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) + .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) + .option('retainedStagingDir', { type: 'boolean', description: 'Should the staging folder be retained after the command is complete', demandOption: false }) + .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) + .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }) + .option('packagePort', { type: 'number', description: 'The port to use for packaging', demandOption: false }) + .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }) + .option('rootDir', { type: 'string', description: 'The root directory', demandOption: false }) + .option('files', { type: 'array', description: 'The files to be included in the package', demandOption: false }) + .option('username', { type: 'string', description: 'The username for the Roku', demandOption: false }) + .usage(`Usage: npx ts-node ./src/cli.ts exec --actions 'stage|zip' --rootDir . --outDir ./out`) + .example( + `npx ts-node ./src/cli.ts exec --actions 'stage|zip' --rootDir . --outDir ./out`, + 'Stages the contents of rootDir and then zips the staged files into outDir - Will fail if there is no manifest in the staging folder' + ); }, (args: any) => { - return new PublishCommand().run(args); + return new ExecCommand(args.actions, args).run(); }) - .command('convertToSquashfs', 'Convert a pre-existing packaged zip file to a squashfs file', (builder) => { + .command('keypress', 'send keypress command', (builder) => { return builder + .option('key', { type: 'string', description: 'The key to send', demandOption: true }) .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); + .option('remoteport', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); }, (args: any) => { - return new ConvertToSquashfsCommand().run(args); + return new KeyPressCommand().run(args); }) - .command('rekeyDevice', 'Rekey a device', (builder) => { + .command('keyup', 'send keyup command', (builder) => { return builder + .option('key', { type: 'string', description: 'The key to send', demandOption: true }) .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('rekeySignedPackage', { type: 'string', description: 'The signed package to be used for rekeying', demandOption: false }) - .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) - .option('rootDir', { type: 'string', description: 'The root directory', demandOption: false }) - .option('devId', { type: 'string', description: 'The dev ID', demandOption: false }); + .option('remoteport', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); }, (args: any) => { - return new RekeyDeviceCommand().run(args); + return new KeyUpCommand().run(args); }) - .command('signExistingPackage', 'Sign a package', (builder) => { + .command('keydown', 'send keydown command', (builder) => { return builder + .option('key', { type: 'string', description: 'The key to send', demandOption: true }) .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }); + .option('remoteport', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); }, (args: any) => { - return new SignExistingPackageCommand().run(args); + return new KeyDownCommand().run(args); }) - .command('retrieveSignedPackage', 'Retrieve a signed package', (builder) => { + .command(['sendText', 'text'], 'Send text command', (builder) => { return builder + .option('text', { type: 'string', description: 'The text to send', demandOption: true }) .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }); + .option('remoteport', { type: 'number', description: 'The port to use for remote', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); + }, (args: any) => { + return new SendTextCommand().run(args); + }) + + .command(['stage', 'prepublishToStaging'], 'Copies all of the referenced files to the staging folder', (builder) => { + return builder + .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) + .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }); }, (args: any) => { - return new RetrieveSignedPackageCommand().run(args); + return new StageCommand().run(args); }) - .command('deploy', 'Deploy a pre-existing packaged zip file to a remote Roku', (builder) => { + .command('sideload', 'Sideload a pre-existing packaged zip file to a remote Roku', (builder) => { return builder .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('rootDir', { type: 'string', description: 'The root directory', demandOption: false }); + .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) + .option('outFile', { type: 'string', description: 'The output file', demandOption: false }); }, (args: any) => { - return new DeployCommand().run(args); + return new SideloadCommand().run(args); }) - .command('deleteInstalledChannel', 'Delete an installed channel', (builder) => { + .command(['squash', 'convertToSquashfs'], 'Convert a pre-existing packaged zip file to a squashfs file', (builder) => { return builder .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); }, (args: any) => { - return new DeleteInstalledChannelCommand().run(args); + return new ConvertToSquashfsCommand().run(args); }) - .command('takeScreenshot', 'Take a screenshot', (builder) => { + .command(['rekey', 'rekeyDevice'], 'Rekey a device', (builder) => { return builder .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); + .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) + .option('rekeySignedPackage', { type: 'string', description: 'The signed package to be used for rekeying', demandOption: false }) + .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) + .option('rootDir', { type: 'string', description: 'The root directory', demandOption: false }) + .option('devId', { type: 'string', description: 'The dev ID', demandOption: false }); }, (args: any) => { - return new TakeScreenshotCommand().run(args); + return new RekeyDeviceCommand().run(args); }) - .command('getOutputZipFilePath', 'Centralizes getting output zip file path based on passed in options', (builder) => { + .command(['createSignedPackage', 'sign'], 'Sign a package', (builder) => { return builder - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }); - return builder; + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) + .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) + .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }); }, (args: any) => { - return new GetOutputZipFilePathCommand().run(args); + return new CreateSignedPackageCommand().run(args); }) - .command('getOutputPkgFilePath', 'Centralizes getting output pkg file path based on passed in options', (builder) => { + .command(['deleteDevChannel', 'deleteInstalledChannel', 'rmdev', 'delete'], 'Delete an installed channel', (builder) => { return builder - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); + }, (args: any) => { + return new DeleteDevChannelCommand().run(args); + }) + + .command(['screenshot', 'captureScreenshot'], 'Take a screenshot', (builder) => { + return builder + .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); }, (args: any) => { - return new GetOutputPkgFilePathCommand().run(args); + return new CaptureScreenshotCommand().run(args); }) - .command('getDeviceInfo', 'Get the `device-info` response from a Roku device', (builder) => { + .command(['getDeviceInfo', 'deviceinfo'], 'Get the `device-info` response from a Roku device', (builder) => { return builder .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }); }, (args: any) => { return new GetDeviceInfoCommand().run(args); }) - .command('getDevId', 'Get Dev ID', (builder) => { + .command(['getDevId', 'devid'], 'Get Dev ID', (builder) => { return builder .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }); }, (args: any) => { return new GetDevIdCommand().run(args); }) - .command('zipFolder', 'Given a path to a folder, zip up that folder and all of its contents', (builder) => { + .command('zip', 'Given a path to a folder, zip up that folder and all of its contents', (builder) => { return builder - .option('srcFolder', { type: 'string', description: 'The folder that should be zipped', demandOption: false }) - .option('zipFilePath', { type: 'string', description: 'The path to the zip that will be created. Must be .zip file name', demandOption: false }); + .option('stagingDir', { type: 'string', description: 'The folder that should be zipped', demandOption: false }) + .option('outDir', { type: 'string', description: 'The path to the zip that will be created. Must be .zip file name', demandOption: false }) + .option('outFile', { type: 'string', description: 'The output file', demandOption: false }); }, (args: any) => { console.log('args', args); - return new ZipFolderCommand().run(args); + return new ZipCommand().run(args); }) .argv; diff --git a/src/commands/CaptureScreenshotCommand.ts b/src/commands/CaptureScreenshotCommand.ts new file mode 100644 index 0000000..6a75ed5 --- /dev/null +++ b/src/commands/CaptureScreenshotCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class CaptureScreenshotCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.captureScreenshot(options); + } +} diff --git a/src/commands/ConvertToSquashfsCommand.ts b/src/commands/ConvertToSquashfsCommand.ts index b1e1e7b..79ebf6e 100644 --- a/src/commands/ConvertToSquashfsCommand.ts +++ b/src/commands/ConvertToSquashfsCommand.ts @@ -1,10 +1,11 @@ -import { rokuDeploy } from '../index'; +import { rokuDeploy, util } from '../index'; export class ConvertToSquashfsCommand { async run(args) { - await rokuDeploy.convertToSquashfs({ - host: args.host, - password: args.password - }); + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.convertToSquashfs(options); } } diff --git a/src/commands/CreatePackageCommand.ts b/src/commands/CreatePackageCommand.ts deleted file mode 100644 index f4b4718..0000000 --- a/src/commands/CreatePackageCommand.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class CreatePackageCommand { - async run(args) { - await rokuDeploy.createPackage({ - stagingDir: args.stagingDir, - outDir: args.outDir, - rootDir: args.rootDir - }); - } -} diff --git a/src/commands/CreateSignedPackageCommand.ts b/src/commands/CreateSignedPackageCommand.ts new file mode 100644 index 0000000..904a9fe --- /dev/null +++ b/src/commands/CreateSignedPackageCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class CreateSignedPackageCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.createSignedPackage(options); + } +} diff --git a/src/commands/DeleteDevChannelCommand.ts b/src/commands/DeleteDevChannelCommand.ts new file mode 100644 index 0000000..5bfa269 --- /dev/null +++ b/src/commands/DeleteDevChannelCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class DeleteDevChannelCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.deleteDevChannel(options); + } +} diff --git a/src/commands/DeleteInstalledChannelCommand.ts b/src/commands/DeleteInstalledChannelCommand.ts deleted file mode 100644 index 1ec9c29..0000000 --- a/src/commands/DeleteInstalledChannelCommand.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class DeleteInstalledChannelCommand { - async run(args) { - await rokuDeploy.deleteInstalledChannel({ - host: args.host, - password: args.password - }); - } -} diff --git a/src/commands/DeployCommand.ts b/src/commands/DeployCommand.ts deleted file mode 100644 index 5947deb..0000000 --- a/src/commands/DeployCommand.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class DeployCommand { - async run(args) { - await rokuDeploy.deploy({ - host: args.host, - password: args.password, - rootDir: args.rootDir - }); - } -} diff --git a/src/commands/ExecCommand.ts b/src/commands/ExecCommand.ts new file mode 100644 index 0000000..04fd034 --- /dev/null +++ b/src/commands/ExecCommand.ts @@ -0,0 +1,66 @@ +import { util } from '../util'; +import { rokuDeploy } from '../RokuDeploy'; +import type { CloseChannelOptions, ConvertToSquashfsOptions, CreateSignedPackageOptions, DeleteDevChannelOptions, RekeyDeviceOptions, SideloadOptions } from '../RokuDeploy'; +import type { RokuDeployOptions } from '../RokuDeployOptions'; + +export class ExecCommand { + private actions: string[]; + + // eslint-disable-next-line @typescript-eslint/ban-types + private options: RokuDeployOptions; + + constructor(actions: string, rokuDeployOptions: RokuDeployOptions) { + this.actions = actions.split('|'); + this.options = rokuDeployOptions; + } + + async run() { + //Load options from json, and overwrite with cli options + this.options = { + ...util.getOptionsFromJson(this.options), + ...this.options + }; + + if (this.actions.includes('stage')) { + await rokuDeploy.stage(this.options); + } + + if (this.actions.includes('zip')) { + await rokuDeploy.zip(this.options); + } + + if (this.actions.includes('delete')) { + try { + await rokuDeploy.deleteDevChannel(this.options as DeleteDevChannelOptions); + } catch (e) { + // note we don't report the error; as we don't actually care that we could not delete - it's just useless noise to log it. + } + } + + if (this.actions.includes('close')) { + await rokuDeploy.closeChannel(this.options as CloseChannelOptions); + } + + if (this.actions.includes('sideload')) { + await rokuDeploy.sideload(this.options as SideloadOptions); + } + + if (this.actions.includes('stage')) { + await rokuDeploy.stage(this.options); + } + + if (this.actions.includes('rekey')) { + await rokuDeploy.rekeyDevice(this.options as RekeyDeviceOptions); + } + + if (this.actions.includes('squash')) { + await rokuDeploy.convertToSquashfs(this.options as ConvertToSquashfsOptions); + } + + if (this.actions.includes('sign')) { + await rokuDeploy.createSignedPackage(this.options as CreateSignedPackageOptions); + } + + + } +} diff --git a/src/commands/GetDevIdCommand.ts b/src/commands/GetDevIdCommand.ts index 72ccde8..c54c8e6 100644 --- a/src/commands/GetDevIdCommand.ts +++ b/src/commands/GetDevIdCommand.ts @@ -1,9 +1,11 @@ -import { rokuDeploy } from '../index'; +import { rokuDeploy, util } from '../index'; export class GetDevIdCommand { async run(args) { - await rokuDeploy.getDevId({ - host: args.host - }); + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.getDevId(options); } } diff --git a/src/commands/GetDeviceInfoCommand.ts b/src/commands/GetDeviceInfoCommand.ts index 1bf10b3..3255fe3 100644 --- a/src/commands/GetDeviceInfoCommand.ts +++ b/src/commands/GetDeviceInfoCommand.ts @@ -3,9 +3,11 @@ import { util } from '../util'; export class GetDeviceInfoCommand { async run(args) { - const outputPath = await rokuDeploy.getDeviceInfo({ - host: args.host - }); + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + const outputPath = await rokuDeploy.getDeviceInfo(options); console.log(util.objectToTableString(outputPath)); } } diff --git a/src/commands/GetOutputPkgFilePathCommand.ts b/src/commands/GetOutputPkgFilePathCommand.ts deleted file mode 100644 index 78fff13..0000000 --- a/src/commands/GetOutputPkgFilePathCommand.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class GetOutputPkgFilePathCommand { - run(args) { - const outputPath = rokuDeploy.getOutputPkgFilePath({ - outFile: args.outFile, - outDir: args.outDir - }); - console.log(outputPath); - } -} diff --git a/src/commands/GetOutputZipFilePathCommand.ts b/src/commands/GetOutputZipFilePathCommand.ts deleted file mode 100644 index 5525057..0000000 --- a/src/commands/GetOutputZipFilePathCommand.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class GetOutputZipFilePathCommand { - run(args) { - const outputPath = rokuDeploy.getOutputZipFilePath({ - outFile: args.outFile, - outDir: args.outDir - }); - console.log(outputPath); - } -} diff --git a/src/commands/KeyDownCommand.ts b/src/commands/KeyDownCommand.ts new file mode 100644 index 0000000..ae05b91 --- /dev/null +++ b/src/commands/KeyDownCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class KeyDownCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.keyDown(options); + } +} diff --git a/src/commands/KeyPressCommand.ts b/src/commands/KeyPressCommand.ts new file mode 100644 index 0000000..4554df7 --- /dev/null +++ b/src/commands/KeyPressCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class KeyPressCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.keyPress(options); + } +} diff --git a/src/commands/KeyUpCommand.ts b/src/commands/KeyUpCommand.ts new file mode 100644 index 0000000..36b71ea --- /dev/null +++ b/src/commands/KeyUpCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class KeyUpCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.keyUp(options); + } +} diff --git a/src/commands/PrepublishCommand.ts b/src/commands/PrepublishCommand.ts deleted file mode 100644 index 2ca65cc..0000000 --- a/src/commands/PrepublishCommand.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class PrepublishCommand { - async run(args) { - await rokuDeploy.prepublishToStaging({ - stagingDir: args.stagingDir, - rootDir: args.rootDir - }); - } -} diff --git a/src/commands/PublishCommand.ts b/src/commands/PublishCommand.ts deleted file mode 100644 index 39a66cc..0000000 --- a/src/commands/PublishCommand.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class PublishCommand { - async run(args) { - await rokuDeploy.publish({ - host: args.host, - password: args.password, - outDir: args.outDir, - outFile: args.outFile - }); - } -} diff --git a/src/commands/RekeyDeviceCommand.ts b/src/commands/RekeyDeviceCommand.ts index 7098772..ca00fd8 100644 --- a/src/commands/RekeyDeviceCommand.ts +++ b/src/commands/RekeyDeviceCommand.ts @@ -1,14 +1,11 @@ -import { rokuDeploy } from '../index'; +import { rokuDeploy, util } from '../index'; export class RekeyDeviceCommand { async run(args) { - await rokuDeploy.rekeyDevice({ - host: args.host, - password: args.password, - rekeySignedPackage: args.rekeySignedPackage, - signingPassword: args.signingPassword, - rootDir: args.rootDir, - devId: args.devId - }); + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.rekeyDevice(options); } } diff --git a/src/commands/RetrieveSignedPackageCommand.ts b/src/commands/RetrieveSignedPackageCommand.ts deleted file mode 100644 index 411dfd9..0000000 --- a/src/commands/RetrieveSignedPackageCommand.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class RetrieveSignedPackageCommand { - async run(args) { - await rokuDeploy.retrieveSignedPackage(args.pathToPkg, { - host: args.host, - password: args.password, - outFile: args.outFile - }); - } -} diff --git a/src/commands/SendTextCommand.ts b/src/commands/SendTextCommand.ts new file mode 100644 index 0000000..3e11810 --- /dev/null +++ b/src/commands/SendTextCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class SendTextCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.sendText(options); + } +} diff --git a/src/commands/SideloadCommand.ts b/src/commands/SideloadCommand.ts new file mode 100644 index 0000000..58c2434 --- /dev/null +++ b/src/commands/SideloadCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class SideloadCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.sideload(options); + } +} diff --git a/src/commands/SignExistingPackageCommand.ts b/src/commands/SignExistingPackageCommand.ts deleted file mode 100644 index 2d5733f..0000000 --- a/src/commands/SignExistingPackageCommand.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class SignExistingPackageCommand { - async run(args) { - await rokuDeploy.signExistingPackage({ - host: args.host, - password: args.password, - signingPassword: args.signingPassword, - stagingDir: args.stagingDir - }); - } -} diff --git a/src/commands/StageCommand.ts b/src/commands/StageCommand.ts new file mode 100644 index 0000000..8cdf460 --- /dev/null +++ b/src/commands/StageCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class StageCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.stage(options); + } +} diff --git a/src/commands/TakeScreenshotCommand.ts b/src/commands/TakeScreenshotCommand.ts deleted file mode 100644 index ac5b7bc..0000000 --- a/src/commands/TakeScreenshotCommand.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class TakeScreenshotCommand { - async run(args) { - await rokuDeploy.takeScreenshot({ - host: args.host, - password: args.password - }); - } -} diff --git a/src/commands/ZipCommand.ts b/src/commands/ZipCommand.ts new file mode 100644 index 0000000..b03e80d --- /dev/null +++ b/src/commands/ZipCommand.ts @@ -0,0 +1,11 @@ +import { rokuDeploy, util } from '../index'; + +export class ZipCommand { + async run(args) { + let options = { + ...util.getOptionsFromJson(args), + ...args + }; + await rokuDeploy.zip(options); + } +} diff --git a/src/commands/ZipFolderCommand.ts b/src/commands/ZipFolderCommand.ts deleted file mode 100644 index edf9078..0000000 --- a/src/commands/ZipFolderCommand.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class ZipFolderCommand { - async run(args) { - await rokuDeploy.zipFolder( - args.srcFolder, - args.zipFilePath - ); - } -} diff --git a/src/commands/ZipPackageCommand.ts b/src/commands/ZipPackageCommand.ts deleted file mode 100644 index 7ef1f09..0000000 --- a/src/commands/ZipPackageCommand.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rokuDeploy } from '../index'; - -export class ZipPackageCommand { - async run(args) { - await rokuDeploy.zipPackage({ - stagingDir: args.stagingDir, - outDir: args.outDir - }); - } -} diff --git a/src/device.spec.ts b/src/device.spec.ts index 8b6d295..094bcfb 100644 --- a/src/device.spec.ts +++ b/src/device.spec.ts @@ -1,27 +1,14 @@ -import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; -import type { RokuDeployOptions } from './index'; -import { rokuDeploy } from './index'; -import { cwd, expectPathExists, expectThrowsAsync, outDir, rootDir, tempDir, writeFiles } from './testUtils.spec'; +import { cwd, rootDir, tempDir, writeFiles } from './testUtils.spec'; import * as dedent from 'dedent'; //these tests are run against an actual roku device. These cannot be enabled when run on the CI server describe('device', function device() { - let options: RokuDeployOptions; beforeEach(() => { fsExtra.emptyDirSync(tempDir); fsExtra.ensureDirSync(rootDir); process.chdir(rootDir); - options = rokuDeploy.getOptions({ - outDir: outDir, - host: '192.168.1.32', - retainDeploymentArchive: true, - password: 'aaaa', - devId: 'c6fdc2019903ac3332f624b0b2c2fe2c733c3e74', - rekeySignedPackage: `${cwd}/testSignedPackage.pkg`, - signingPassword: 'drRCEVWP/++K5TYnTtuAfQ==' - }); writeFiles(rootDir, [ ['manifest', dedent` @@ -62,31 +49,4 @@ describe('device', function device() { }); this.timeout(20000); - - describe('deploy', () => { - it('works', async () => { - options.retainDeploymentArchive = true; - let response = await rokuDeploy.deploy(options as any); - assert.equal(response.message, 'Successful deploy'); - }); - - it('Presents nice message for 401 unauthorized status code', async () => { - this.timeout(20000); - options.password = 'NOT_THE_PASSWORD'; - await expectThrowsAsync( - rokuDeploy.deploy(options as any), - 'Unauthorized. Please verify username and password for target Roku.' - ); - }); - }); - - describe('deployAndSignPackage', () => { - it('works', async () => { - await rokuDeploy.deleteInstalledChannel(options as any); - await rokuDeploy.rekeyDevice(options as any); - expectPathExists( - await rokuDeploy.deployAndSignPackage(options as any) - ); - }); - }); }); diff --git a/src/index.ts b/src/index.ts index 8c00ae0..2fc0ec7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,6 @@ -import { RokuDeploy } from './RokuDeploy'; - //export everything from the RokuDeploy file 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 f58c18c..f4047f7 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -1,7 +1,7 @@ import { util, standardizePath as s } from './util'; import { expect } from 'chai'; import * as fsExtra from 'fs-extra'; -import { tempDir } from './testUtils.spec'; +import { cwd, tempDir, rootDir } from './testUtils.spec'; import * as path from 'path'; import * as dns from 'dns'; import { createSandbox } from 'sinon'; @@ -14,6 +14,7 @@ describe('util', () => { }); afterEach(() => { + fsExtra.emptyDirSync(tempDir); sinon.restore(); }); @@ -109,10 +110,10 @@ describe('util', () => { }); describe('globAllByIndex', () => { - function writeFiles(filePaths: string[], cwd = tempDir) { + function writeFiles(filePaths: string[], dir = tempDir) { for (const filePath of filePaths) { fsExtra.outputFileSync( - path.resolve(cwd, filePath), + path.resolve(dir, filePath), '' ); } @@ -334,4 +335,195 @@ describe('util', () => { expect(result).to.eql(expectedOutput); }); }); + + describe('normalizeRootDir', () => { + it('handles falsey values', () => { + expect(util.normalizeRootDir(null)).to.equal(cwd); + expect(util.normalizeRootDir(undefined)).to.equal(cwd); + expect(util.normalizeRootDir('')).to.equal(cwd); + expect(util.normalizeRootDir(' ')).to.equal(cwd); + expect(util.normalizeRootDir('\t')).to.equal(cwd); + }); + + it('handles non-falsey values', () => { + expect(util.normalizeRootDir(cwd)).to.equal(cwd); + expect(util.normalizeRootDir('./')).to.equal(cwd); + expect(util.normalizeRootDir('./testProject')).to.equal(path.join(cwd, 'testProject')); + }); + }); + + describe('getDestPath', () => { + it('handles unrelated exclusions properly', () => { + expect( + util.getDestPath( + s`${rootDir}/components/comp1/comp1.brs`, + [ + '**/*', + '!exclude.me' + ], + rootDir + ) + ).to.equal(s`components/comp1/comp1.brs`); + }); + + it('finds dest path for top-level path', () => { + expect( + util.getDestPath( + s`${rootDir}/components/comp1/comp1.brs`, + ['components/**/*'], + rootDir + ) + ).to.equal(s`components/comp1/comp1.brs`); + }); + + it('does not find dest path for non-matched top-level path', () => { + expect( + util.getDestPath( + s`${rootDir}/source/main.brs`, + ['components/**/*'], + rootDir + ) + ).to.be.undefined; + }); + + it('excludes a file that is negated', () => { + expect( + util.getDestPath( + s`${rootDir}/source/main.brs`, + [ + 'source/**/*', + '!source/main.brs' + ], + rootDir + ) + ).to.be.undefined; + }); + + it('excludes file from non-rootdir top-level pattern', () => { + expect( + util.getDestPath( + s`${rootDir}/../externalDir/source/main.brs`, + [ + '!../externalDir/**/*' + ], + rootDir + ) + ).to.be.undefined; + }); + + it('excludes a file that is negated in src;dest;', () => { + expect( + util.getDestPath( + s`${rootDir}/source/main.brs`, + [ + 'source/**/*', + { + src: '!source/main.brs' + } + ], + rootDir + ) + ).to.be.undefined; + }); + + it('works for brighterscript files', () => { + let destPath = util.getDestPath( + util.standardizePath(`${cwd}/src/source/main.bs`), + [ + 'manifest', + 'source/**/*.bs' + ], + s`${cwd}/src` + ); + expect(s`${destPath}`).to.equal(s`source/main.bs`); + }); + + it('excludes a file found outside the root dir', () => { + expect( + util.getDestPath( + s`${rootDir}/../source/main.brs`, + [ + '../source/**/*' + ], + rootDir + ) + ).to.be.undefined; + }); + }); + + describe('getOptionsFromJson', () => { + it('should fill in options from rokudeploy.json', () => { + fsExtra.outputJsonSync(s`${rootDir}/rokudeploy.json`, { password: 'password' }); + expect( + util.getOptionsFromJson({ cwd: rootDir }) + ).to.eql({ + password: 'password' + }); + }); + + it(`loads cwd from process`, () => { + try { + fsExtra.outputJsonSync(s`${process.cwd()}/rokudeploy.json`, { host: '1.2.3.4' }); + expect( + util.getOptionsFromJson() + ).to.eql({ + host: '1.2.3.4' + }); + } finally { + fsExtra.removeSync(s`${process.cwd()}/rokudeploy.json`); + } + }); + + it('catches invalid json with jsonc parser', () => { + fsExtra.writeJsonSync(s`${process.cwd()}/rokudeploy.json`, { host: '1.2.3.4' }); + sinon.stub(fsExtra, 'readFileSync').returns(` + { + "rootDir": "src" + `); + let ex; + try { + util.getOptionsFromJson(); + } catch (e) { + console.log(e); + ex = e; + } + expect(ex).to.exist; + expect(ex.message.startsWith('Error parsing')).to.be.true; + fsExtra.removeSync(s`${process.cwd()}/rokudeploy.json`); + }); + + it('works when loading stagingDir from rokudeploy.json', () => { + sinon.stub(fsExtra, 'existsSync').callsFake((filePath) => { + return true; + }); + sinon.stub(fsExtra, 'readFileSync').returns(` + { + "stagingDir": "./staging-dir" + } + `); + let options = util.getOptionsFromJson(); + expect(options.stagingDir.endsWith('staging-dir')).to.be.true; + }); + + it('supports jsonc for rokudeploy.json', () => { + fsExtra.writeFileSync(s`${tempDir}/rokudeploy.json`, ` + //leading comment + { + //inner comment + "rootDir": "src" //trailing comment + } + //trailing comment + `); + let options = util.getOptionsFromJson({ cwd: tempDir }); + expect(options.rootDir).to.equal('src'); + }); + }); + + describe('computeFileDestPath', () => { + it('treats {src;dest} without dest as a top-level string', () => { + expect( + util['computeFileDestPath'](s`${rootDir}/source/main.brs`, { src: s`source/main.brs` } as any, rootDir) + ).to.eql(s`source/main.brs`); + }); + }); }); diff --git a/src/util.ts b/src/util.ts index ca7d7d4..bc11ead 100644 --- a/src/util.ts +++ b/src/util.ts @@ -5,6 +5,12 @@ import * as dns from 'dns'; import * as micromatch from 'micromatch'; // eslint-disable-next-line @typescript-eslint/no-require-imports import fastGlob = require('fast-glob'); +import type { FileEntry, RokuDeployOptions } from './RokuDeployOptions'; +import type { StandardizedFileEntry } from './RokuDeploy'; +import * as isGlob from 'is-glob'; +import * as picomatch from 'picomatch'; +import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'; +import type { ParseError } from 'jsonc-parser'; export class Util { /** @@ -225,6 +231,202 @@ export class Util { }); } + /** + * Given an array of `FilesType`, normalize them each into a `StandardizedFileEntry`. + * Each entry in the array or inner `src` array will be extracted out into its own object. + * This makes it easier to reason about later on in the process. + * @param files + */ + public normalizeFilesArray(files: FileEntry[]) { + const result: Array = []; + + for (let i = 0; i < files.length; i++) { + let entry = files[i]; + //skip falsey and blank entries + if (!entry) { + continue; + + //string entries + } else if (typeof entry === 'string') { + result.push(entry); + + //objects with src: (string | string[]) + } else if ('src' in entry) { + //validate dest + if (entry.dest !== undefined && entry.dest !== null && typeof entry.dest !== 'string') { + throw new Error(`Invalid type for "dest" at index ${i} of files array`); + } + + //objects with src: string + if (typeof entry.src === 'string') { + result.push({ + src: entry.src, + dest: util.standardizePath(entry.dest) + }); + + //objects with src:string[] + } else if ('src' in entry && Array.isArray(entry.src)) { + //create a distinct entry for each item in the src array + for (let srcEntry of entry.src) { + result.push({ + src: srcEntry, + dest: util.standardizePath(entry.dest) + }); + } + } else { + throw new Error(`Invalid type for "src" at index ${i} of files array`); + } + } else { + throw new Error(`Invalid entry at index ${i} in files array`); + } + } + + return result; + } + + /** + * Given a full path to a file, determine its dest path + * @param srcPath the absolute path to the file. This MUST be a file path, and it is not verified to exist on the filesystem + * @param files the files array + * @param rootDir the absolute path to the root dir + * @param skipMatch - skip running the minimatch process (i.e. assume the file is a match + * @returns the RELATIVE path to the dest location for the file. + */ + public getDestPath(srcPathAbsolute: string, files: FileEntry[], rootDir: string, skipMatch = false) { + srcPathAbsolute = util.standardizePath(srcPathAbsolute); + rootDir = rootDir.replace(/\\+/g, '/'); + const entries = util.normalizeFilesArray(files); + + function makeGlobAbsolute(pattern: string) { + return path.resolve( + path.posix.join( + rootDir, + //remove leading exclamation point if pattern is negated + pattern + //coerce all slashes to forward + ) + ).replace(/\\/g, '/'); + } + + let result: string; + + //add the file into every matching cache bucket + for (let entry of entries) { + const pattern = (typeof entry === 'string' ? entry : entry.src); + //filter previous paths + if (pattern.startsWith('!')) { + const keepFile = picomatch('!' + makeGlobAbsolute(pattern.replace(/^!/, ''))); + if (!keepFile(srcPathAbsolute)) { + result = undefined; + } + } else { + const keepFile = picomatch(makeGlobAbsolute(pattern)); + if (keepFile(srcPathAbsolute)) { + try { + result = this.computeFileDestPath( + srcPathAbsolute, + entry, + util.standardizePath(rootDir) + ); + } catch { + //ignore errors...the file just has no dest path + } + } + } + } + return result; + } + + /** + * Compute the `dest` path. This accounts for magic globstars in the pattern, + * as well as relative paths based on the dest. This is only used internally. + * @param src an absolute, normalized path for a file + * @param dest the `dest` entry for this file. If omitted, files will derive their paths relative to rootDir. + * @param pattern the glob pattern originally used to find this file + * @param rootDir absolute normalized path to the rootDir + */ + public computeFileDestPath(srcPath: string, entry: string | StandardizedFileEntry, rootDir: string) { + let result: string; + let globstarIdx: number; + //files under rootDir with no specified dest + if (typeof entry === 'string') { + if (util.isParentOfPath(rootDir, srcPath, false)) { + //files that are actually relative to rootDir + result = util.stringReplaceInsensitive(srcPath, rootDir, ''); + } else { + // result = util.stringReplaceInsensitive(srcPath, rootDir, ''); + throw new Error('Cannot reference a file outside of rootDir when using a top-level string. Please use a src;des; object instead'); + } + + //non-glob-pattern explicit file reference + } else if (!isGlob(entry.src.replace(/\\/g, '/'), { strict: false })) { + let isEntrySrcAbsolute = path.isAbsolute(entry.src); + let entrySrcPathAbsolute = isEntrySrcAbsolute ? entry.src : util.standardizePath(`${rootDir}/${entry.src}`); + + let isSrcChildOfRootDir = util.isParentOfPath(rootDir, entrySrcPathAbsolute, false); + + let fileNameAndExtension = path.basename(entrySrcPathAbsolute); + + //no dest + if (entry.dest === null || entry.dest === undefined) { + //no dest, absolute path or file outside of rootDir + if (isEntrySrcAbsolute || isSrcChildOfRootDir === false) { + //copy file to root of staging folder + result = fileNameAndExtension; + + //no dest, relative path, lives INSIDE rootDir + } else { + //copy relative file structure to root of staging folder + let srcPathRelative = util.stringReplaceInsensitive(entrySrcPathAbsolute, rootDir, ''); + result = srcPathRelative; + } + + //assume entry.dest is the relative path to the folder AND file if applicable + } else if (entry.dest === '') { + result = fileNameAndExtension; + } else { + result = entry.dest; + } + //has a globstar + } else if ((globstarIdx = entry.src.indexOf('**')) > -1) { + const rootGlobstarPath = path.resolve(rootDir, entry.src.substring(0, globstarIdx)) + path.sep; + const srcPathRelative = util.stringReplaceInsensitive(srcPath, rootGlobstarPath, ''); + if (entry.dest) { + result = `${entry.dest}/${srcPathRelative}`; + } else { + result = srcPathRelative; + } + + //`pattern` is some other glob magic + } else { + const fileNameAndExtension = path.basename(srcPath); + if (entry.dest) { + result = util.standardizePath(`${entry.dest}/${fileNameAndExtension}`); + } else { + result = util.stringReplaceInsensitive(srcPath, rootDir, ''); + } + } + + result = util.standardizePath( + //remove leading slashes + result.replace(/^[\/\\]+/, '') + ); + return result; + } + + /** + * Given a root directory, normalize it to a full path. + * Fall back to cwd if not specified + * @param rootDir + */ + public normalizeRootDir(rootDir: string) { + if (!rootDir || (typeof rootDir === 'string' && rootDir.trim().length === 0)) { + return process.cwd(); + } else { + return path.resolve(rootDir); + } + } + public objectToTableString(deviceInfo: Record) { const margin = 5; const keyWidth = Math.max(...Object.keys(deviceInfo).map(x => x.length)) + margin; @@ -239,6 +441,37 @@ export class Util { return table.join('\n'); } + /** + * A function to fill in any missing arguments with JSON values + * Only run when CLI commands are used + */ + public getOptionsFromJson(options?: { cwd?: string }) { + let fileOptions: RokuDeployOptions = {}; + const cwd = options?.cwd ?? process.cwd(); + const configPath = path.join(cwd, 'rokudeploy.json'); + + if (fsExtra.existsSync(configPath)) { + let configFileText = fsExtra.readFileSync(configPath).toString(); + let parseErrors = [] as ParseError[]; + fileOptions = parseJsonc(configFileText, parseErrors, { + allowEmptyContent: true, + allowTrailingComma: true, + disallowComments: false + }); + if (parseErrors.length > 0) { + throw new Error(`Error parsing "${path.resolve(configPath)}": ` + JSON.stringify( + parseErrors.map(x => { + return { + message: printParseErrorCode(x.error), + offset: x.offset, + length: x.length + }; + }) + )); + } + } + return fileOptions; + } } export let util = new Util();