diff --git a/README.md b/README.md index 8565879..087f994 100644 --- a/README.md +++ b/README.md @@ -624,10 +624,6 @@ Here are the available options for customizing to your developer-specific workfl - **packageUploadOverrides?:** Overrides for values used during the zip upload process. You probably don't need to change these... -- **logLevel?:** string = `"error"` - The level lof logging information printed in the console. You can read more information [here](https://github.com/rokucommunity/logger) - - Click [here](https://github.com/rokucommunity/roku-deploy/blob/v4/src/RokuDeployOptions.ts) to see the typescript interface for these options diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index 97ae902..e246dec 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -4348,6 +4348,131 @@ describe('RokuDeploy', () => { } describe('defaults', () => { + describe('constructor defaults', () => { + describe('host option', () => { + it('fails when not provided in constructor or call', async () => { + const rd = new RokuDeploy(); + await expectThrowsAsync(async () => { + await rd['sendKeyEvent']({ key: 'home', action: 'keypress' } as any); + }, 'Missing required option: host'); + }); + + it('uses constructor value when not provided in call', async () => { + const rd = new RokuDeploy({ host: 'constructor-host' }); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({}); + await rd['sendKeyEvent']({ key: 'home', action: 'keypress' } as any); + expect(stub.getCall(0).args[0].url).to.include('constructor-host'); + }); + + it('uses call value when not provided in constructor', async () => { + const rd = new RokuDeploy(); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({}); + await rd['sendKeyEvent']({ host: 'call-host', key: 'home', action: 'keypress' }); + expect(stub.getCall(0).args[0].url).to.include('call-host'); + }); + + it('call value overrides constructor value', async () => { + const rd = new RokuDeploy({ host: 'constructor-host' }); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({}); + await rd['sendKeyEvent']({ host: 'call-host', key: 'home', action: 'keypress' }); + expect(stub.getCall(0).args[0].url).to.include('call-host'); + }); + }); + + describe('password option', () => { + it('fails when not provided in constructor or call', async () => { + const rd = new RokuDeploy(); + await expectThrowsAsync(async () => { + await rd.deleteDevChannel({ host: 'localhost' } as any); + }, 'Missing required option: password'); + }); + + it('uses constructor value when not provided in call', async () => { + const rd = new RokuDeploy({ host: 'localhost', password: 'constructor-pass' }); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({ body: '', response: { statusCode: 200 } }); + await rd.deleteDevChannel(); + expect(stub.getCall(0).args[0].auth.pass).to.equal('constructor-pass'); + }); + + it('call value overrides constructor value', async () => { + const rd = new RokuDeploy({ host: 'localhost', password: 'constructor-pass' }); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({ body: '', response: { statusCode: 200 } }); + await rd.deleteDevChannel({ password: 'call-pass' } as any); + expect(stub.getCall(0).args[0].auth.pass).to.equal('call-pass'); + }); + }); + + describe('ecpPort option', () => { + it('uses static default when not provided anywhere', async () => { + const rd = new RokuDeploy(); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({}); + await rd['sendKeyEvent']({ host: 'localhost', key: 'home', action: 'keypress' }); + expect(stub.getCall(0).args[0].url).to.include(':8060/'); + }); + + it('uses constructor value when not provided in call', async () => { + const rd = new RokuDeploy({ ecpPort: 9000 }); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({}); + await rd['sendKeyEvent']({ host: 'localhost', key: 'home', action: 'keypress' }); + expect(stub.getCall(0).args[0].url).to.include(':9000/'); + }); + + it('call value overrides constructor value', async () => { + const rd = new RokuDeploy({ ecpPort: 9000 }); + const stub = sinon.stub(rd as any, 'doPostRequest').resolves({}); + await rd['sendKeyEvent']({ host: 'localhost', key: 'home', action: 'keypress', ecpPort: 9999 }); + expect(stub.getCall(0).args[0].url).to.include(':9999/'); + }); + }); + + describe('packagePort option', () => { + it('uses static default when not provided anywhere', () => { + const rd = new RokuDeploy(); + const result = rd['generateBaseRequestOptions']('test', { host: 'localhost', password: 'test' }); + expect(result.url).to.include(':80/'); + }); + + it('uses constructor value when not provided in call', () => { + const rd = new RokuDeploy({ packagePort: 8080 }); + const result = rd['generateBaseRequestOptions']('test', { host: 'localhost', password: 'test' }); + expect(result.url).to.include(':8080/'); + }); + + it('call value overrides constructor value', () => { + const rd = new RokuDeploy({ packagePort: 8080 }); + const result = rd['generateBaseRequestOptions']('test', { host: 'localhost', password: 'test', packagePort: 9090 }); + expect(result.url).to.include(':9090/'); + }); + }); + + describe('logger option', () => { + it('uses global logger when not provided in constructor', () => { + const rd = new RokuDeploy(); + expect(rd.logger).to.exist; + }); + + it('uses custom logger when provided in constructor', () => { + const customLogger = { + logLevel: 'off' as any, + log: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + debug: sinon.stub(), + error: sinon.stub(), + trace: sinon.stub() + }; + const rd = new RokuDeploy({ logger: customLogger as any }); + expect(rd.logger).to.equal(customLogger); + }); + + it('allows setting logLevel on logger after construction', () => { + const rd = new RokuDeploy(); + rd.logger.logLevel = 'debug'; + expect(rd.logger.logLevel).to.equal('debug'); + }); + }); + }); + describe('generateBaseRequestOptions', () => { it('uses default timeout', () => { const result = rokuDeploy['generateBaseRequestOptions']('test', { host: 'localhost', password: 'test' }); diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index e9c6f43..a5ba5cc 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -9,8 +9,8 @@ import * as errors from './Errors'; import * as xml2js from 'xml2js'; import { parse as parseJsonc } from 'jsonc-parser'; import { util } from './util'; -import type { FileEntry } from './RokuDeployOptions'; -import { logger, type LogLevel } from '@rokucommunity/logger'; +import type { FileEntry, RokuDeployConstructorOptions } from './RokuDeployOptions'; +import { logger } from '@rokucommunity/logger'; import * as dayjs from 'dayjs'; import * as lodash from 'lodash'; import type { DeviceInfo, DeviceInfoRaw } from './DeviceInfo'; @@ -29,12 +29,45 @@ export class RokuDeploy { outFile: 'roku-deploy.zip' }; + /** + * Instance-level default options that are merged into every method call + */ + private readonly options: RokuDeployConstructorOptions; + + /** + * The logger instance for this RokuDeploy instance. Can be used to set log level: + * @example + * ```typescript + * rokuDeploy.logger.logLevel = 'debug'; + * ``` + */ + public readonly logger: typeof logger; + + /** + * Create a new RokuDeploy instance with optional default options. + * These defaults will be merged into every method call, so you don't need to + * repeat common options like `host` and `password` on every call. + * + * @example + * ```typescript + * const rd = new RokuDeploy({ host: '192.168.1.100', password: 'secret' }); + * await rd.sideload({ rootDir: './dist' }); // host and password come from defaults + * ``` + */ + constructor(options?: RokuDeployConstructorOptions) { + this.options = options ?? {}; + + // Use custom logger if provided, otherwise use global logger + this.logger = this.options.logger ?? logger; + } + /** * Copies all of the referenced files to the staging folder * @param options */ public async stage(options: StageOptions) { - logger.info('Beginning to copy files to staging folder'); + options = { ...this.options, ...options }; + this.logger.info('Beginning to copy files to staging folder'); const cwd = options.cwd ?? process.cwd(); // Set defaults and resolve paths @@ -73,7 +106,7 @@ export class RokuDeploy { }); }, 10); })); - logger.info('Relevant files copied to:', out); + this.logger.info('Relevant files copied to:', out); return out; } @@ -82,7 +115,8 @@ export class RokuDeploy { * @param options */ public async zip(options: ZipOptions) { - logger.info('Beginning to zip'); + options = { ...this.options, ...options }; + this.logger.info('Beginning to zip'); const cwd = options.cwd ?? process.cwd(); // dir is required @@ -111,9 +145,11 @@ export class RokuDeploy { if (!hasManifest) { throw new Error(`Cannot zip package: missing manifest file in "${dir}"`); } - + + //create a zip of the folder await this.makeZip(dir, out, files); - logger.info('Zip created at:', out); + this.logger.info('Zip created at:', out); + } /** @@ -157,6 +193,7 @@ export class RokuDeploy { * Get all file paths for the specified options */ public async getFilePaths(options: GetFilePathsOptions): Promise { + options = { ...this.options, ...options } as GetFilePathsOptions; let rootDir = options.rootDir; const files = options.files; @@ -198,18 +235,20 @@ export class RokuDeploy { } private generateBaseRequestOptions(requestPath: string, options: BaseRequestOptions, formData = {} as T): requestType.OptionsWithUrl { + // Merge constructor options with call options + const mergedOptions = { ...this.options, ...options }; // Set defaults for request options - const packagePort = options.packagePort ?? RokuDeploy.defaults.packagePort; - const timeout = options.timeout ?? RokuDeploy.defaults.timeout; - const username = options.username ?? 'rokudev'; + const packagePort = mergedOptions.packagePort ?? RokuDeploy.defaults.packagePort; + const timeout = mergedOptions.timeout ?? RokuDeploy.defaults.timeout; + const username = mergedOptions.username ?? 'rokudev'; - let url = `http://${options.host}:${packagePort}/${requestPath}`; + let url = `http://${mergedOptions.host}:${packagePort}/${requestPath}`; let baseRequestOptions = { url: url, timeout: timeout, auth: { user: username, - pass: options.password, + pass: mergedOptions.password, sendImmediately: false }, formData: formData, @@ -219,6 +258,7 @@ export class RokuDeploy { } public async keyPress(options: KeyPressOptions) { + options = { ...this.options, ...options } as KeyPressOptions; return this.sendKeyEvent({ ...options, key: options.key, @@ -227,6 +267,7 @@ export class RokuDeploy { } public async keyUp(options: KeyUpOptions) { + options = { ...this.options, ...options } as KeyUpOptions; return this.sendKeyEvent({ ...options, action: 'keyup' @@ -234,6 +275,7 @@ export class RokuDeploy { } public async keyDown(options: KeyDownOptions) { + options = { ...this.options, ...options } as KeyDownOptions; return this.sendKeyEvent({ ...options, action: 'keydown' @@ -241,6 +283,7 @@ export class RokuDeploy { } public async sendText(options: SendTextOptions) { + options = { ...this.options, ...options } as SendTextOptions; this.checkRequiredOptions(options, ['host', 'text']); const chars = options.text.split(''); for (const char of chars) { @@ -257,7 +300,8 @@ export class RokuDeploy { * This makes the roku return to the home screen */ private async sendKeyEvent(options: SendKeyEventOptions) { - logger.info('Sending key event:', options.key); + options = { ...this.options, ...options } as SendKeyEventOptions; + this.logger.info('Sending key event:', options.key); this.checkRequiredOptions(options, ['host', 'key']); // Set defaults const ecpPort = options.ecpPort ?? RokuDeploy.defaults.ecpPort; @@ -270,6 +314,7 @@ export class RokuDeploy { } public async closeChannel(options: CloseChannelOptions) { + options = { ...this.options, ...options } as 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, @@ -284,7 +329,8 @@ export class RokuDeploy { * @param options */ public async sideload(options: SideloadOptions): Promise<{ message: string; results: any }> { - logger.info('Beginning to sideload package'); + options = { ...this.options, ...options } as SideloadOptions; + this.logger.info('Beginning to sideload package'); this.checkRequiredOptions(options, ['host', 'password']); const cwd = options.cwd ?? process.cwd(); @@ -402,7 +448,7 @@ export class RokuDeploy { if (response.body.indexOf('Identical to previous version -- not replacing.') > -1) { return { message: 'Identical to previous version -- not replacing', results: response }; } - logger.info('Successful sideload'); + this.logger.info('Successful sideload'); return { message: 'Successful sideload', results: response }; } finally { //delete the zip file if we generated it from rootDir @@ -413,7 +459,7 @@ export class RokuDeploy { try { readStream?.close(); } catch (e) { - logger.warn('Error closing read stream', e); + this.logger.warn('Error closing read stream', e); } } } @@ -444,6 +490,7 @@ export class RokuDeploy { * @param options */ public async convertToSquashfs(options: ConvertToSquashfsOptions) { + options = { ...this.options, ...options } as ConvertToSquashfsOptions; this.checkRequiredOptions(options, ['host', 'password']); let requestOptions = this.generateBaseRequestOptions('plugin_install', options, { archive: '', @@ -464,7 +511,7 @@ export class RokuDeploy { return results; } } catch (e) { - logger.warn('Error converting to squashfs:', error); + this.logger.warn('Error converting to squashfs:', error); throw error; } } else { @@ -481,6 +528,7 @@ export class RokuDeploy { * @param options */ public async rekeyDevice(options: RekeyDeviceOptions) { + options = { ...this.options, ...options } as RekeyDeviceOptions; this.checkRequiredOptions(options, ['host', 'password', 'pkg', 'signingPassword']); const cwd = path.resolve(process.cwd(), options.cwd ?? '.'); @@ -528,7 +576,8 @@ export class RokuDeploy { * @param options */ public async createSignedPackage(options: CreateSignedPackageOptions): Promise { - logger.info('Creating signed package'); + options = { ...this.options, ...options } as CreateSignedPackageOptions; + this.logger.info('Creating signed package'); this.checkRequiredOptions(options, ['host', 'password', 'signingPassword']); const cwd = options.cwd ?? process.cwd(); @@ -598,7 +647,7 @@ export class RokuDeploy { const url = pkgSearchMatches[1]; let requestOptions2 = this.generateBaseRequestOptions(url, options); await this.downloadFile(requestOptions2, out); - logger.info('Signed package created at:', out); + this.logger.info('Signed package created at:', out); return out; } @@ -645,7 +694,7 @@ export class RokuDeploy { * @param params */ private async doPostRequest(params: requestType.OptionsWithUrl, verify = true) { - logger.info('handling POST request to', params.url); + this.logger.info('handling POST request to', params.url); let results: { response: any; body: any } = await new Promise((resolve, reject) => { this.setUserAgentIfMissing(params); @@ -668,7 +717,7 @@ export class RokuDeploy { * @param params */ private async doGetRequest(params: requestType.OptionsWithUrl) { - logger.info('handling GET request to', params.url); + this.logger.info('handling GET request to', params.url); let results: { response: any; body: any } = await new Promise((resolve, reject) => { this.setUserAgentIfMissing(params); @@ -819,7 +868,8 @@ export class RokuDeploy { * @param options */ public async deleteDevChannel(options?: DeleteDevChannelOptions) { - logger.info('Deleting dev channel...'); + options = { ...this.options, ...options } as DeleteDevChannelOptions; + this.logger.info('Deleting dev channel...'); this.checkRequiredOptions(options, ['host', 'password']); let deleteOptions = this.generateBaseRequestOptions('plugin_install', options); @@ -834,6 +884,7 @@ export class RokuDeploy { * Delete the component library with the specified filename from the device */ public async deleteComponentLibrary(options?: { host: string; password: string; fileName: string; username?: string }) { + options = { ...this.options, ...options } as { host: string; password: string; fileName: string; username?: string }; this.checkRequiredOptions(options, ['host', 'password', 'fileName']); let deleteOptions = this.generateBaseRequestOptions('plugin_install', options); @@ -852,6 +903,7 @@ export class RokuDeploy { * Delete all component libraries from the device */ public async deleteAllComponentLibraries(options: { host: string; password: string; username?: string }) { + options = { ...this.options, ...options } as { host: string; password: string; username?: string }; const packages = await this.getInstalledPackages(options); for (const pkg of packages) { if (pkg.appType === 'dcl') { @@ -867,6 +919,7 @@ export class RokuDeploy { * Fetch the full list of installed packages from the device. Useful for finding the file names of installed component libraries or the dev channel. */ private async getInstalledPackages(options: { host: string; password: string; username?: string }): Promise { + options = { ...this.options, ...options } as { host: string; password: string; username?: string }; this.checkRequiredOptions(options, ['host', 'password']); let deleteOptions = this.generateBaseRequestOptions('plugin_install', options); deleteOptions.qs ??= {}; @@ -881,6 +934,7 @@ export class RokuDeploy { * Gets a screenshot from the device. A side-loaded channel must be running or an error will be thrown. */ public async captureScreenshot(options: CaptureScreenshotOptions) { + options = { ...this.options, ...options } as CaptureScreenshotOptions; this.checkRequiredOptions(options, ['host', 'password']); const cwd = options.cwd ?? process.cwd(); const screenshotDir = options.screenshotDir @@ -990,6 +1044,7 @@ export class RokuDeploy { public async getDeviceInfo(options?: GetDeviceInfoOptions & { enhance: true }): Promise; public async getDeviceInfo(options?: GetDeviceInfoOptions): Promise; public async getDeviceInfo(options: GetDeviceInfoOptions) { + options = { ...this.options, ...options } as GetDeviceInfoOptions; this.checkRequiredOptions(options, ['host']); // Set defaults @@ -1035,10 +1090,10 @@ export class RokuDeploy { } deviceInfo = result; } - logger.debug('Device info:', deviceInfo); + this.logger.debug('Device info:', deviceInfo); return deviceInfo; } catch (e) { - logger.warn('Error getting device info:', e); + this.logger.warn('Error getting device info:', e); throw new errors.UnparsableDeviceResponseError('Could not retrieve device info', response); } } @@ -1055,6 +1110,7 @@ export class RokuDeploy { * - 'permissive': Full access for internal networks */ public async getEcpNetworkAccessMode(options: GetDeviceInfoOptions): Promise { + options = { ...this.options, ...options } as GetDeviceInfoOptions; try { const deviceInfo = await this.getDeviceInfo(options); return deviceInfo['ecp-setting-mode']; @@ -1092,9 +1148,10 @@ export class RokuDeploy { * @returns */ public async getDevId(options?: GetDevIdOptions) { + options = { ...this.options, ...options } as GetDevIdOptions; this.checkRequiredOptions(options, ['host']); const deviceInfo = await this.getDeviceInfo(options); - logger.debug('Found dev id:', deviceInfo['keyed-developer-id']); + this.logger.debug('Found dev id:', deviceInfo['keyed-developer-id']); return deviceInfo['keyed-developer-id']; } @@ -1125,6 +1182,7 @@ export class RokuDeploy { } public async rebootDevice(options: RebootDeviceOptions) { + options = { ...this.options, ...options } as RebootDeviceOptions; this.checkRequiredOptions(options, ['host', 'password']); // Get device info to check software version @@ -1146,6 +1204,7 @@ export class RokuDeploy { } public async checkForUpdate(options: CheckForUpdateOptions) { + options = { ...this.options, ...options } as CheckForUpdateOptions; this.checkRequiredOptions(options, ['host', 'password']); // Get device info to check software version @@ -1345,7 +1404,6 @@ export interface BaseRequestOptions { password: string; packagePort?: number; timeout?: number; - logLevel?: LogLevel; } export interface BaseEcpOptions { diff --git a/src/RokuDeployOptions.ts b/src/RokuDeployOptions.ts index 0769df8..ed31924 100644 --- a/src/RokuDeployOptions.ts +++ b/src/RokuDeployOptions.ts @@ -1,6 +1,48 @@ -import type { LogLevel } from '@rokucommunity/logger'; +import type { Logger } from '@rokucommunity/logger'; + +/** + * Options that can be passed to the RokuDeploy constructor to set defaults + * that will be merged into every method call. + */ +export interface RokuDeployConstructorOptions { + /** + * A custom logger instance. If not provided, the global logger will be used. + */ + logger?: Logger; + /** + * The IP address or hostname of the target Roku device. + * @example '192.168.1.21' + */ + host?: string; + /** + * The password for logging in to the developer portal on the target Roku device + */ + password?: string; + /** + * The username for the roku box. This will always be 'rokudev', but allows to be overridden + * just in case roku adds support for custom usernames in the future + * @default 'rokudev' + */ + username?: string; + /** + * The port that should be used when installing the package. Defaults to 80. + */ + packagePort?: number; + /** + * The port used to send remote control commands (like home press, back, etc.). Defaults to 8060. + */ + ecpPort?: number; + /** + * The request timeout duration in milliseconds. Defaults to 150000ms (2 minutes 30 seconds). + */ + timeout?: number; +} export interface RokuDeployOptions { + /** + * A custom logger instance. If not provided, the global logger will be used. + */ + logger?: Logger; /** * The working directory where the command should be executed */ @@ -119,12 +161,6 @@ export interface RokuDeployOptions { */ failOnCompileError?: boolean; - /** - * The log level. - * @default LogLevel.log - */ - logLevel?: LogLevel; - /** * If true, the previously installed dev channel will be deleted before installing the new one */