From 620fac517cdccdc6c75ee839a8929276d45327bf Mon Sep 17 00:00:00 2001 From: Brendan Burns Date: Fri, 21 Dec 2018 07:42:12 -0800 Subject: [PATCH] Improve test coverage, fix some bugs. --- src/config.ts | 151 ++++++++++++++++--------------- src/config_test.ts | 170 +++++++++++++++++++++++++++++------ src/exec_auth.ts | 4 +- src/portforward.ts | 11 ++- src/portforward_test.ts | 191 +++++++++++++++++++++++++++------------- 5 files changed, 360 insertions(+), 167 deletions(-) diff --git a/src/config.ts b/src/config.ts index 96f01cb1dd..d879e731bd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,49 +14,6 @@ import { Cluster, Context, newClusters, newContexts, newUsers, User } from './co import { ExecAuth } from './exec_auth'; export class KubeConfig { - // Only public for testing. - public static findHomeDir(): string | null { - if (process.env.HOME) { - try { - fs.accessSync(process.env.HOME); - return process.env.HOME; - // tslint:disable-next-line:no-empty - } catch (ignore) {} - } - if (process.platform !== 'win32') { - return null; - } - if (process.env.HOMEDRIVE && process.env.HOMEPATH) { - const dir = path.join(process.env.HOMEDRIVE, process.env.HOMEPATH); - try { - fs.accessSync(dir); - return dir; - // tslint:disable-next-line:no-empty - } catch (ignore) {} - } - if (process.env.USERPROFILE) { - try { - fs.accessSync(process.env.USERPROFILE); - return process.env.USERPROFILE; - // tslint:disable-next-line:no-empty - } catch (ignore) {} - } - return null; - } - - // Only really public for testing... - public static findObject(list: any[], name: string, key: string) { - for (const obj of list) { - if (obj.name === name) { - if (obj[key]) { - return obj[key]; - } - return obj; - } - } - return null; - } - private static authenticators: Authenticator[] = [ new CloudAuth(), new ExecAuth(), @@ -106,7 +63,7 @@ export class KubeConfig { if (!this.contexts) { return null; } - return KubeConfig.findObject(this.contexts, name, 'context'); + return findObject(this.contexts, name, 'context'); } public getCurrentCluster(): Cluster | null { @@ -117,16 +74,20 @@ export class KubeConfig { return this.getCluster(context.cluster); } - public getCluster(name: string): Cluster { - return KubeConfig.findObject(this.clusters, name, 'cluster'); + public getCluster(name: string): Cluster | null { + return findObject(this.clusters, name, 'cluster'); } - public getCurrentUser() { - return this.getUser(this.getCurrentContextObject().user); + public getCurrentUser(): User | null { + const ctx = this.getCurrentContextObject(); + if (!ctx) { + return null; + } + return this.getUser(ctx.user); } - public getUser(name: string): User { - return KubeConfig.findObject(this.users, name, 'user'); + public getUser(name: string): User | null { + return findObject(this.users, name, 'user'); } public loadFromFile(file: string) { @@ -138,7 +99,7 @@ export class KubeConfig { this.applyOptions(opts); - if (user.username) { + if (user && user.username) { opts.auth = `${user.username}:${user.password}`; } } @@ -233,7 +194,7 @@ export class KubeConfig { this.loadFromFile(process.env.KUBECONFIG); return; } - const home = KubeConfig.findHomeDir(); + const home = findHomeDir(); if (home) { const config = path.join(home, '.kube', 'config'); try { @@ -260,8 +221,8 @@ export class KubeConfig { } catch (ignore) {} this.loadFromClusterAndUser( - {name: 'cluster', server: 'http://localhost:8080'} as Cluster, - {name: 'user'} as User, + { name: 'cluster', server: 'http://localhost:8080' } as Cluster, + { name: 'user' } as User, ); } @@ -280,31 +241,21 @@ export class KubeConfig { return this.getContextObject(this.currentContext); } - private bufferFromFileOrString(file?: string, data?: string): Buffer | null { - if (file) { - return fs.readFileSync(file); - } - if (data) { - return Buffer.from(base64.decode(data), 'utf-8'); - } - return null; - } - private applyHTTPSOptions(opts: request.Options | https.RequestOptions) { const cluster = this.getCurrentCluster(); const user = this.getCurrentUser(); if (!user) { return; } - const ca = cluster != null ? this.bufferFromFileOrString(cluster.caFile, cluster.caData) : null; + const ca = cluster != null ? bufferFromFileOrString(cluster.caFile, cluster.caData) : null; if (ca) { opts.ca = ca; } - const cert = this.bufferFromFileOrString(user.certFile, user.certData); + const cert = bufferFromFileOrString(user.certFile, user.certData); if (cert) { opts.cert = cert; } - const key = this.bufferFromFileOrString(user.keyFile, user.keyData); + const key = bufferFromFileOrString(user.keyFile, user.keyData); if (key) { opts.key = key; } @@ -349,17 +300,17 @@ export interface ApiType { } export interface ApiConstructor { - new (server: string): T; + new(server: string): T; } // This class is deprecated and will eventually be removed. export class Config { public static SERVICEACCOUNT_ROOT = - '/var/run/secrets/kubernetes.io/serviceaccount'; + '/var/run/secrets/kubernetes.io/serviceaccount'; public static SERVICEACCOUNT_CA_PATH = - Config.SERVICEACCOUNT_ROOT + '/ca.crt'; + Config.SERVICEACCOUNT_ROOT + '/ca.crt'; public static SERVICEACCOUNT_TOKEN_PATH = - Config.SERVICEACCOUNT_ROOT + '/token'; + Config.SERVICEACCOUNT_ROOT + '/token'; public static fromFile(filename: string): api.Core_v1Api { return Config.apiFromFile(filename, api.Core_v1Api); @@ -400,3 +351,61 @@ export class Config { return kc.makeApiClient(apiClientType); } } + +// This is public really only for testing. +export function bufferFromFileOrString(file ?: string, data ?: string): Buffer | null { + if (file) { + return fs.readFileSync(file); + } + if (data) { + return Buffer.from(base64.decode(data), 'utf-8'); + } + return null; +} + +// Only public for testing. +export function findHomeDir(): string | null { + if (process.env.HOME) { + try { + fs.accessSync(process.env.HOME); + return process.env.HOME; + // tslint:disable-next-line:no-empty + } catch (ignore) { } + } + if (process.platform !== 'win32') { + return null; + } + if (process.env.HOMEDRIVE && process.env.HOMEPATH) { + const dir = path.join(process.env.HOMEDRIVE, process.env.HOMEPATH); + try { + fs.accessSync(dir); + return dir; + // tslint:disable-next-line:no-empty + } catch (ignore) { } + } + if (process.env.USERPROFILE) { + try { + fs.accessSync(process.env.USERPROFILE); + return process.env.USERPROFILE; + // tslint:disable-next-line:no-empty + } catch (ignore) {} + } + return null; +} + +export interface Named { + name: string; +} + +// Only really public for testing... +export function findObject(list: T[], name: string, key: string): T | null { + for (const obj of list) { + if (obj.name === name) { + if (obj[key]) { + return obj[key]; + } + return obj; + } + } + return null; +} diff --git a/src/config_test.ts b/src/config_test.ts index d7ee46337d..bb019d6570 100644 --- a/src/config_test.ts +++ b/src/config_test.ts @@ -7,7 +7,7 @@ import mockfs = require('mock-fs'); import * as requestlib from 'request'; import { Core_v1Api } from './api'; -import { KubeConfig } from './config'; +import { bufferFromFileOrString, findHomeDir, findObject, KubeConfig, Named } from './config'; import { Cluster, newClusters, newContexts, newUsers, User } from './config_types'; const kcFileName = 'testdata/kubeconfig.yaml'; @@ -56,33 +56,42 @@ function validateFileLoad(kc: KubeConfig) { describe('KubeConfig', () => { describe('findObject', () => { it('should find objects', () => { - const list = [ + interface MyNamed { + name: string; + some: any; + cluster: any; + } + const list: MyNamed[] = [ { name: 'foo', cluster: { some: 'sub-object', }, some: 'object', - }, + } as MyNamed, { name: 'bar', some: 'object', cluster: { - sone: 'sub-object', + some: 'sub-object', }, - }, + } as MyNamed, ]; // Validate that if the named object ('cluster' in this case) is inside we pick it out - const obj1 = KubeConfig.findObject(list, 'foo', 'cluster'); - expect(obj1.some).to.equal('sub-object'); - + const obj1 = findObject(list, 'foo', 'cluster'); + expect(obj1).to.not.equal(null); + if (obj1) { + expect(obj1.some).to.equal('sub-object'); + } // Validate that if the named object is missing, we just return the full object - const obj2 = KubeConfig.findObject(list, 'bar', 'context'); - expect(obj2.some).to.equal('object'); - + const obj2 = findObject(list, 'bar', 'context'); + expect(obj2).to.not.equal(null); + if (obj2) { + expect(obj2.some).to.equal('object'); + } // validate that we do the right thing if it is missing - const obj3 = KubeConfig.findObject(list, 'nonexistent', 'context'); + const obj3 = findObject(list, 'nonexistent', 'context'); expect(obj3).to.equal(null); }); }); @@ -332,7 +341,7 @@ describe('KubeConfig', () => { arg[dir] = { config: 'data' }; mockfs(arg); - const home = KubeConfig.findHomeDir(); + const home = findHomeDir(); mockfs.restore(); process.env.HOME = currentHome; @@ -343,24 +352,41 @@ describe('KubeConfig', () => { describe('win32HomeDirTests', () => { let originalPlatform: string; + const originalEnvVars: any = {}; before(() => { originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32', }); + originalEnvVars.HOME = process.env.HOME; + originalEnvVars.USERPROFILE = process.env.USERPROFILE; + originalEnvVars.HOMEDRIVE = process.env.HOMEDRIVE; + originalEnvVars.HOMEPATH = process.env.HOMEPATH; + + delete process.env.HOME; + delete process.env.USERPROFILE; + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; }); after(() => { Object.defineProperty(process, 'platform', { value: originalPlatform, }); + + process.env.HOME = originalEnvVars.HOME; + process.env.USERPROFILE = originalEnvVars.USERPROFILE; + process.env.HOMEDRIVE = originalEnvVars.HOMEDRIVE; + process.env.HOMEPATH = originalEnvVars.HOMEPATH; }); - it('should load from HOMEDRIVE/HOMEPATH if present', () => { - const currentHome = process.env.HOME; + it('should return null if no home is present', () => { + const dir = findHomeDir(); + expect(dir).to.equal(null); + }); - delete process.env.HOME; + it('should load from HOMEDRIVE/HOMEPATH if present', () => { process.env.HOMEDRIVE = 'foo'; process.env.HOMEPATH = 'bar'; const dir = join(process.env.HOMEDRIVE, process.env.HOMEPATH); @@ -368,19 +394,15 @@ describe('KubeConfig', () => { arg[dir] = { config: 'data' }; mockfs(arg); - const home = KubeConfig.findHomeDir(); + const home = findHomeDir(); mockfs.restore(); - process.env.HOME = currentHome; - expect(home).to.equal(dir); }); it('should load from USERPROFILE if present', () => { - const currentHome = process.env.HOME; const dir = 'someplace'; - delete process.env.HOME; process.env.HOMEDRIVE = 'foo'; process.env.HOMEPATH = 'bar'; process.env.USERPROFILE = dir; @@ -388,11 +410,9 @@ describe('KubeConfig', () => { arg[dir] = { config: 'data' }; mockfs(arg); - const home = KubeConfig.findHomeDir(); + const home = findHomeDir(); mockfs.restore(); - process.env.HOME = currentHome; - expect(home).to.equal(dir); }); }); @@ -623,8 +643,7 @@ describe('KubeConfig', () => { expect(opts.headers.Authorization).to.equal(`Bearer ${token}`); } }); - - it('should exec with exec auth', () => { + it('should exec with exec auth and env vars', () => { const config = new KubeConfig(); const token = 'token'; const responseStr = `'{ "token": "${token}" }'`; @@ -647,6 +666,31 @@ describe('KubeConfig', () => { }, }, } as User); + // TODO: inject the exec command here and validate env vars? + const opts = {} as requestlib.Options; + config.applyToRequest(opts); + expect(opts.headers).to.not.be.undefined; + if (opts.headers) { + expect(opts.headers.Authorization).to.equal(`Bearer ${token}`); + } + }); + it('should exec with exec auth', () => { + const config = new KubeConfig(); + const token = 'token'; + const responseStr = `'{ "token": "${token}" }'`; + config.loadFromClusterAndUser( + { skipTLSVerify: false } as Cluster, + { + authProvider: { + name: 'exec', + config: { + exec: { + command: 'echo', + args: [`${responseStr}`], + }, + }, + }, + } as User); // TODO: inject the exec command here? const opts = {} as requestlib.Options; config.applyToRequest(opts); @@ -655,6 +699,22 @@ describe('KubeConfig', () => { expect(opts.headers.Authorization).to.equal(`Bearer ${token}`); } }); + it('should throw with no command.', () => { + const config = new KubeConfig(); + config.loadFromClusterAndUser( + { skipTLSVerify: false } as Cluster, + { + authProvider: { + name: 'exec', + config: { + exec: { + }, + }, + }, + } as User); + const opts = {} as requestlib.Options; + expect(() => config.applyToRequest(opts)).to.throw('No command was specified for exec authProvider!'); + }); }); describe('loadFromDefault', () => { @@ -711,7 +771,11 @@ describe('KubeConfig', () => { } expect(cluster.caFile).to.equal('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'); expect(cluster.server).to.equal('https://kubernetes:443'); - expect(kc.getCurrentUser().token).to.equal(token); + const user = kc.getCurrentUser(); + expect(user).to.not.be.null; + if (user) { + expect(user.token).to.equal(token); + } }); it('should load from cluster with http port', () => { @@ -752,8 +816,14 @@ describe('KubeConfig', () => { if (!cluster) { return; } - expect(kc.getCurrentUser().name).to.equal('user'); + expect(cluster.name).to.equal('cluster'); expect(cluster.server).to.equal('http://localhost:8080'); + + const user = kc.getCurrentUser(); + expect(user).to.not.be.null; + if (user) { + expect(user.name).to.equal('user'); + } }); }); @@ -766,4 +836,48 @@ describe('KubeConfig', () => { expect(client instanceof Core_v1Api).to.equal(true); }); }); + + describe('EmptyConfig', () => { + const emptyConfig = new KubeConfig(); + it('should throw if you try to make a client', () => { + expect(() => emptyConfig.makeApiClient(Core_v1Api)).to.throw('No active cluster!'); + }); + + it('should get a null current cluster', () => { + expect(emptyConfig.getCurrentCluster()).to.equal(null); + }); + + it('should get empty user', () => { + expect(emptyConfig.getCurrentUser()).to.equal(null); + }); + + it('should get empty cluster', () => { + expect(emptyConfig.getCurrentCluster()).to.equal(null); + }); + + it('should get empty context', () => { + expect(emptyConfig.getCurrentContext()).to.be.undefined; + }); + + it('should apply to request', () => { + const opts = {} as requestlib.Options; + emptyConfig.applyToRequest(opts); + }); + }); + + describe('BufferOrFile', () => { + it('should load from a file if present', () => { + const data = 'some data for file'; + const arg: any = { + config: data, + }; + mockfs(arg); + const inputData = bufferFromFileOrString('config'); + expect(inputData).to.not.equal(null); + if (inputData) { + expect(inputData.toString()).to.equal(data); + } + mockfs.restore(); + }); + }); }); diff --git a/src/exec_auth.ts b/src/exec_auth.ts index 2fd68fe6d1..831a402a68 100644 --- a/src/exec_auth.ts +++ b/src/exec_auth.ts @@ -21,9 +21,7 @@ export class ExecAuth implements Authenticator { let opts: shell.ExecOpts; if (config.exec.env) { const env = {}; - if (config.exec.env) { - config.exec.env.forEach((elt) => env[elt.name] = elt.value); - } + config.exec.env.forEach((elt) => env[elt.name] = elt.value); opts = { env }; } const result = shell.exec(cmd, opts); diff --git a/src/portforward.ts b/src/portforward.ts index 582befb20e..7d7cde8bd0 100644 --- a/src/portforward.ts +++ b/src/portforward.ts @@ -1,6 +1,7 @@ import WebSocket = require('isomorphic-ws'); import querystring = require('querystring'); import stream = require('stream'); +import { isUndefined } from 'util'; import { KubeConfig } from './config'; import { WebSocketHandler, WebSocketInterface } from './web-socket-handler'; @@ -16,13 +17,13 @@ export class PortForward { } else { this.handler = handler; } - this.disconnectOnErr = true; + this.disconnectOnErr = isUndefined(disconnectOnErr) ? true : disconnectOnErr; } // TODO: support multiple ports for real... public async portForward( namespace: string, podName: string, targetPorts: number[], - output: stream.Writable, err: stream.Writable, + output: stream.Writable, err: stream.Writable | null, input: stream.Readable, ): Promise { @@ -30,7 +31,7 @@ export class PortForward { throw new Error('You must provide at least one port to forward to.'); } if (targetPorts.length > 1) { - throw(new Error('ERROR: Only one port is currently supported for port-forward')); + throw(new Error('Only one port is currently supported for port-forward')); } const query = { ports: targetPorts[0], @@ -44,9 +45,7 @@ export class PortForward { const path = `/api/v1/namespaces/${namespace}/pods/${podName}/portforward?${queryStr}`; const conn = await this.handler.connect(path, null, (streamNum: number, buff: Buffer | string): boolean => { if (streamNum >= targetPorts.length * 2) { - if (this.disconnectOnErr) { - return false; - } + return !this.disconnectOnErr; } // First two bytes of each stream are the port number if (needsToReadPortNumber[streamNum]) { diff --git a/src/portforward_test.ts b/src/portforward_test.ts index 33f2df10fb..28360e6f15 100644 --- a/src/portforward_test.ts +++ b/src/portforward_test.ts @@ -7,64 +7,137 @@ import { PortForward } from './portforward'; import { WebSocketHandler, WebSocketInterface } from './web-socket-handler'; describe('PortForward', () => { - describe('basic', () => { - it('should correctly port-forward to a url', async () => { - const kc = new KubeConfig(); - const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler); - const portForward = new PortForward(kc, true, instance(fakeWebSocket)); - const osStream = new WritableStreamBuffer(); - const errStream = new WritableStreamBuffer(); - const isStream = new ReadableStreamBuffer(); - - const namespace = 'somenamespace'; - const pod = 'somepod'; - const port = 8080; - - await portForward.portForward( - namespace, pod, [port], osStream, errStream, isStream); - - const path = `/api/v1/namespaces/${namespace}/pods/${pod}/portforward?ports=${port}`; - verify(fakeWebSocket.connect(path, null, anyFunction())).called(); - }); - - it('should correctly port-forward streams', async () => { - const kc = new KubeConfig(); - const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler); - const portForward = new PortForward(kc, true, instance(fakeWebSocket)); - const osStream = new WritableStreamBuffer(); - const errStream = new WritableStreamBuffer(); - const isStream = new ReadableStreamBuffer(); - - await portForward.portForward( - 'ns', 'p', [8000], osStream, errStream, isStream); - - const [ , , outputFn] = capture(fakeWebSocket.connect).last(); - - /* tslint:disable:no-unused-expression */ - expect(outputFn).to.not.be.null; - // this is redundant but needed for the compiler, sigh... - if (outputFn) { - const buffer = Buffer.alloc(1024, 10); - - outputFn(0, buffer); - // first time, drop two bytes for the port number. - expect(osStream.size()).to.equal(1022); - - outputFn(0, buffer); - expect(osStream.size()).to.equal(2046); - - // error stream, drop two bytes for the port number. - outputFn(1, buffer); - expect(errStream.size()).to.equal(1022); - - outputFn(1, buffer); - expect(errStream.size()).to.equal(2046); - - // unknown stream, shouldn't change anything. - outputFn(2, buffer); - expect(osStream.size()).to.equal(2046); - expect(errStream.size()).to.equal(2046); - } - }); + it('should correctly port-forward to a url', async () => { + const kc = new KubeConfig(); + const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler); + const portForward = new PortForward(kc, true, instance(fakeWebSocket)); + const osStream = new WritableStreamBuffer(); + const errStream = new WritableStreamBuffer(); + const isStream = new ReadableStreamBuffer(); + + const namespace = 'somenamespace'; + const pod = 'somepod'; + const port = 8080; + + await portForward.portForward( + namespace, pod, [port], osStream, errStream, isStream); + + const path = `/api/v1/namespaces/${namespace}/pods/${pod}/portforward?ports=${port}`; + verify(fakeWebSocket.connect(path, null, anyFunction())).called(); + }); + + it('should not disconnect if disconnectOnErr is false', async () => { + const kc = new KubeConfig(); + const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler); + const portForward = new PortForward(kc, false, instance(fakeWebSocket)); + const osStream = new WritableStreamBuffer(); + const isStream = new ReadableStreamBuffer(); + + const conn = await portForward.portForward( + 'ns', 'p', [8000], osStream, null, isStream); + + const [, , outputFn] = capture(fakeWebSocket.connect).last(); + + /* tslint:disable:no-unused-expression */ + expect(outputFn).to.not.be.null; + // this is redundant but needed for the compiler, sigh... + if (!outputFn) { + return; + } + const buffer = Buffer.alloc(1024, 10); + + // unknown stream shouldn't close the socket. + outputFn(2, buffer); + + outputFn(0, buffer); + // first time, drop two bytes for the port number. + expect(osStream.size()).to.equal(1022); + }); + + it('should correctly port-forward streams if err is null', async () => { + const kc = new KubeConfig(); + const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler); + const portForward = new PortForward(kc, true, instance(fakeWebSocket)); + const osStream = new WritableStreamBuffer(); + const isStream = new ReadableStreamBuffer(); + + await portForward.portForward( + 'ns', 'p', [8000], osStream, null, isStream); + + const [, , outputFn] = capture(fakeWebSocket.connect).last(); + + /* tslint:disable:no-unused-expression */ + expect(outputFn).to.not.be.null; + // this is redundant but needed for the compiler, sigh... + if (!outputFn) { + return; + } + const buffer = Buffer.alloc(1024, 10); + + // error stream, drop two bytes for the port number. + outputFn(1, buffer); + // error stream is null, expect output to be dropped and nothing to change. + expect(osStream.size()).to.equal(0); + }); + + it('should correctly port-forward streams', async () => { + const kc = new KubeConfig(); + const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler); + const portForward = new PortForward(kc, true, instance(fakeWebSocket)); + const osStream = new WritableStreamBuffer(); + const errStream = new WritableStreamBuffer(); + const isStream = new ReadableStreamBuffer(); + + await portForward.portForward( + 'ns', 'p', [8000], osStream, errStream, isStream); + + const [, , outputFn] = capture(fakeWebSocket.connect).last(); + + /* tslint:disable:no-unused-expression */ + expect(outputFn).to.not.be.null; + // this is redundant but needed for the compiler, sigh... + if (!outputFn) { + return; + } + const buffer = Buffer.alloc(1024, 10); + + outputFn(0, buffer); + // first time, drop two bytes for the port number. + expect(osStream.size()).to.equal(1022); + + outputFn(0, buffer); + expect(osStream.size()).to.equal(2046); + + // error stream, drop two bytes for the port number. + outputFn(1, buffer); + expect(errStream.size()).to.equal(1022); + + outputFn(1, buffer); + expect(errStream.size()).to.equal(2046); + + // unknown stream, shouldn't change anything. + outputFn(2, buffer); + expect(osStream.size()).to.equal(2046); + expect(errStream.size()).to.equal(2046); + }); + + it('should throw with no ports or too many', async () => { + const kc = new KubeConfig(); + const portForward = new PortForward(kc); + const osStream = new WritableStreamBuffer(); + const isStream = new ReadableStreamBuffer(); + + try { + await portForward.portForward('ns', 'pod', [], osStream, osStream, isStream); + expect(false, 'should have thrown').to.equal(true); + } catch (err) { + expect(err.toString()).to.equal('Error: You must provide at least one port to forward to.'); + } + try { + await portForward.portForward('ns', 'pod', [1, 2], osStream, osStream, isStream); + expect(false, 'should have thrown').to.equal(true); + } catch (err) { + expect(err.toString()).to.equal('Error: Only one port is currently supported for port-forward'); + } }); });