diff --git a/lib/actions/device.coffee b/lib/actions/device.coffee index 3cf8a61f..7f121560 100644 --- a/lib/actions/device.coffee +++ b/lib/actions/device.coffee @@ -53,7 +53,8 @@ exports.list = .tap (devices) -> devices = _.map devices, (device) -> device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid) - device.application_name = device.belongs_to__application[0].app_name + device.application_name = + if device.belongs_to__application?[0] then device.belongs_to__application[0].app_name else 'N/a' device.uuid = device.uuid.slice(0, 7) return device @@ -93,7 +94,8 @@ exports.info = balena.models.device.getStatus(device).then (status) -> device.status = status device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid) - device.application_name = device.belongs_to__application[0].app_name + device.application_name = + if device.belongs_to__application?[0] then device.belongs_to__application[0].app_name else 'N/a' device.commit = device.is_on__commit console.log visuals.table.vertical device, [ @@ -345,6 +347,8 @@ exports.move = patterns = require('../utils/patterns') balena.models.device.get(params.uuid, expandForAppName).then (device) -> + device.application_name = + if device.belongs_to__application?[0] then device.belongs_to__application[0].app_name else 'N/a' return options.application if options.application return Promise.all([ @@ -359,7 +363,7 @@ exports.move = return patterns.selectApplication (application) -> return _.every [ _.some(compatibleDeviceTypes, (dt) -> dt.slug == application.device_type) - device.belongs_to__application[0].app_name isnt application.app_name + device.application_name isnt application.app_name ] .tap (application) -> return balena.models.device.move(params.uuid, application) diff --git a/tests/balena-api-mock.ts b/tests/balena-api-mock.ts index 1d3e3021..44ff8b2c 100644 --- a/tests/balena-api-mock.ts +++ b/tests/balena-api-mock.ts @@ -213,6 +213,22 @@ export class BalenaAPIMock { // (Also, nock should automatically throw an error, but also not happening) // For now, the console.error is sufficient (will fail the test) } + + public debug() { + const scope = this.scope; + let mocks = scope.pendingMocks(); + console.error(`pending mocks ${mocks.length}: ${mocks}`); + + this.scope.on('request', function(_req, _interceptor, _body) { + console.log(`>> REQUEST:` + _req.path); + mocks = scope.pendingMocks(); + console.error(`pending mocks ${mocks.length}: ${mocks}`); + }); + + this.scope.on('replied', function(_req) { + console.log(`<< REPLIED:` + _req.path); + }); + } } const appServiceVarsByService: { [key: string]: any } = { diff --git a/tests/commands/device/device-move.spec.ts b/tests/commands/device/device-move.spec.ts new file mode 100644 index 00000000..ebdbd2a6 --- /dev/null +++ b/tests/commands/device/device-move.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { BalenaAPIMock } from '../../balena-api-mock'; +import { cleanOutput, runCommand } from '../../helpers'; + +const HELP_RESPONSE = ` +Usage: device move + +Use this command to move a device to another application you own. + +If you omit the application, you'll get asked for it interactively. + +Examples: + +\t$ balena device move 7cf02a6 +\t$ balena device move 7cf02a6 --application MyNewApp + +Options: + + --application, -a, --app application name +`; + +describe('balena device move', function() { + let api: BalenaAPIMock; + + beforeEach(() => { + api = new BalenaAPIMock(); + }); + + afterEach(() => { + // Check all expected api calls have been made and clean up. + api.done(); + }); + + it('should print help text with the -h flag', async () => { + api.expectWhoAmI(); + api.expectMixpanel(); + + const { out, err } = await runCommand('device move -h'); + + expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE])); + + expect(err).to.eql([]); + }); + + it.skip('should error if uuid not provided', async () => { + // TODO: Figure out how to test for expected errors with current setup + // including exit codes if possible. + api.expectWhoAmI(); + api.expectMixpanel(); + + const { out, err } = await runCommand('device move'); + const errLines = cleanOutput(err); + + expect(errLines[0]).to.equal('Missing uuid'); + expect(out).to.eql([]); + }); + + // TODO: Test to add once nock matching issues resolved: + // - 'should perform device move if application name provided' + // - 'should start interactive selection of application name if none provided' + // - 'correctly handles devices with missing application' +}); diff --git a/tests/commands/device/supported.api-response.json b/tests/commands/device/device-types.api-response.json similarity index 100% rename from tests/commands/device/supported.api-response.json rename to tests/commands/device/device-types.api-response.json diff --git a/tests/commands/device/device.api-response.json b/tests/commands/device/device.api-response.json new file mode 100644 index 00000000..c922c6e3 --- /dev/null +++ b/tests/commands/device/device.api-response.json @@ -0,0 +1,64 @@ +{ + "d": [ + { + "belongs_to__application": [ + { + "app_name": "test app", + "__metadata": {} + } + ], + "id": 1747415, + "belongs_to__user": { + "__deferred": { + "uri": "/resin/user(46272)" + }, + "__id": 46272 + }, + "is_managed_by__device": null, + "actor": 4180757, + "should_be_running__release": null, + "device_name": "sparkling-wood", + "device_type": "raspberrypi4-64", + "uuid": "fda508c8583011b8466c26abdd5159f2", + "is_on__commit": "18756d3386c25a044db66b89e0409804", + "note": null, + "local_id": null, + "status": "Idle", + "is_online": false, + "last_connectivity_event": "2019-11-23T00:26:35.074Z", + "is_connected_to_vpn": false, + "last_vpn_event": "2019-11-23T00:26:35.074Z", + "ip_address": "192.168.0.112", + "vpn_address": null, + "public_address": "89.186.29.129", + "os_version": "balenaOS 2.44.0+rev3", + "os_variant": "dev", + "supervisor_version": "10.3.7", + "should_be_managed_by__supervisor_release": null, + "is_managed_by__service_instance": { + "__deferred": { + "uri": "/resin/service_instance(124111)" + }, + "__id": 124111 + }, + "provisioning_progress": null, + "provisioning_state": "", + "download_progress": null, + "is_web_accessible": false, + "longitude": "22.5853", + "latitude": "51.2712", + "location": "Lublin, Lublin, Poland", + "custom_longitude": "", + "custom_latitude": "", + "logs_channel": null, + "is_locked_until__date": null, + "is_accessible_by_support_until__date": null, + "created_at": "2019-11-18T12:27:37.423Z", + "is_active": true, + "api_heartbeat_state": "offline", + "__metadata": { + "uri": "/resin/device(@id)?@id=1747415" + } + } + ] +} diff --git a/tests/commands/device/device.api-response.missing-app.json b/tests/commands/device/device.api-response.missing-app.json new file mode 100644 index 00000000..b224b622 --- /dev/null +++ b/tests/commands/device/device.api-response.missing-app.json @@ -0,0 +1,60 @@ +{ + "d": [ + { + "belongs_to__application": [ + ], + "id": 1747415, + "belongs_to__user": { + "__deferred": { + "uri": "/resin/user(46272)" + }, + "__id": 46272 + }, + "is_managed_by__device": null, + "actor": 4180757, + "should_be_running__release": null, + "device_name": "sparkling-wood", + "device_type": "raspberrypi4-64", + "uuid": "fda508c8583011b8466c26abdd5159f2", + "is_on__commit": "18756d3386c25a044db66b89e0409804", + "note": null, + "local_id": null, + "status": "Idle", + "is_online": false, + "last_connectivity_event": "2019-11-23T00:26:35.074Z", + "is_connected_to_vpn": false, + "last_vpn_event": "2019-11-23T00:26:35.074Z", + "ip_address": "192.168.0.112", + "vpn_address": null, + "public_address": "89.186.29.129", + "os_version": "balenaOS 2.44.0+rev3", + "os_variant": "dev", + "supervisor_version": "10.3.7", + "should_be_managed_by__supervisor_release": null, + "is_managed_by__service_instance": { + "__deferred": { + "uri": "/resin/service_instance(124111)" + }, + "__id": 124111 + }, + "provisioning_progress": null, + "provisioning_state": "", + "download_progress": null, + "is_web_accessible": false, + "longitude": "22.5853", + "latitude": "51.2712", + "location": "Lublin, Lublin, Poland", + "custom_longitude": "", + "custom_latitude": "", + "logs_channel": null, + "is_locked_until__date": null, + "is_accessible_by_support_until__date": null, + "created_at": "2019-11-18T12:27:37.423Z", + "is_active": true, + "api_heartbeat_state": "offline", + "__metadata": { + "uri": "/resin/device(@id)?@id=1747415" + } + } + ] +} diff --git a/tests/commands/device/device.spec.ts b/tests/commands/device/device.spec.ts new file mode 100644 index 00000000..ad9a117b --- /dev/null +++ b/tests/commands/device/device.spec.ts @@ -0,0 +1,94 @@ +import { expect } from 'chai'; +import { BalenaAPIMock } from '../../balena-api-mock'; +import { cleanOutput, runCommand } from '../../helpers'; + +const HELP_RESPONSE = ` +Usage: device + +Use this command to show information about a single device. + +Examples: + +\t$ balena device 7cf02a6 +`; + +describe('balena device', function() { + let api: BalenaAPIMock; + + beforeEach(() => { + api = new BalenaAPIMock(); + }); + + afterEach(() => { + // Check all expected api calls have been made and clean up. + api.done(); + }); + + it('should print help text with the -h flag', async () => { + api.expectWhoAmI(); + api.expectMixpanel(); + + const { out, err } = await runCommand('device -h'); + + expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE])); + + expect(err).to.eql([]); + }); + + it.skip('should error if uuid not provided', async () => { + // TODO: Figure out how to test for expected errors with current setup + // including exit codes if possible. + api.expectWhoAmI(); + api.expectMixpanel(); + + const { out, err } = await runCommand('device'); + const errLines = cleanOutput(err); + + expect(errLines[0]).to.equal('Missing uuid'); + expect(out).to.eql([]); + }); + + it('should list device details for provided uuid', async () => { + api.expectWhoAmI(); + api.expectMixpanel(); + + api.scope + .get(/^\/v5\/device/) + .replyWithFile(200, __dirname + '/device.api-response.json', { + 'Content-Type': 'application/json', + }); + + const { out, err } = await runCommand('device 27fda508c'); + + const lines = cleanOutput(out); + + expect(lines).to.have.lengthOf(13); + expect(lines[0]).to.equal('== SPARKLING WOOD'); + expect(lines[6].split(':')[1].trim()).to.equal('test app'); + + expect(err).to.eql([]); + }); + + it('correctly handles devices with missing application', async () => { + // Devices with missing applications will have application name set to `N/a`. + // e.g. When user has a device associated with app that user is no longer a collaborator of. + api.expectWhoAmI(); + api.expectMixpanel(); + + api.scope + .get(/^\/v5\/device/) + .replyWithFile(200, __dirname + '/device.api-response.missing-app.json', { + 'Content-Type': 'application/json', + }); + + const { out, err } = await runCommand('device 27fda508c'); + + const lines = cleanOutput(out); + + expect(lines).to.have.lengthOf(13); + expect(lines[0]).to.equal('== SPARKLING WOOD'); + expect(lines[6].split(':')[1].trim()).to.equal('N/a'); + + expect(err).to.eql([]); + }); +}); diff --git a/tests/commands/device/devices.api-response.json b/tests/commands/device/devices.api-response.json new file mode 100644 index 00000000..0c9d4116 --- /dev/null +++ b/tests/commands/device/devices.api-response.json @@ -0,0 +1,120 @@ +{ + "d": [ + { + "belongs_to__application": [ + { + "app_name": "test app", + "__metadata": {} + } + ], + "id": 1747415, + "belongs_to__user": { + "__deferred": { + "uri": "/resin/user(46272)" + }, + "__id": 46272 + }, + "is_managed_by__device": null, + "actor": 4180757, + "should_be_running__release": null, + "device_name": "sparkling-wood", + "device_type": "raspberrypi4-64", + "uuid": "fda508c8583011b8466c26abdd5159f2", + "is_on__commit": "18756d3386c25a044db66b89e0409804", + "note": null, + "local_id": null, + "status": "Idle", + "is_online": false, + "last_connectivity_event": "2019-11-23T00:26:35.074Z", + "is_connected_to_vpn": false, + "last_vpn_event": "2019-11-23T00:26:35.074Z", + "ip_address": "192.168.0.112", + "vpn_address": null, + "public_address": "89.186.29.129", + "os_version": "balenaOS 2.44.0+rev3", + "os_variant": "dev", + "supervisor_version": "10.3.7", + "should_be_managed_by__supervisor_release": null, + "is_managed_by__service_instance": { + "__deferred": { + "uri": "/resin/service_instance(124111)" + }, + "__id": 124111 + }, + "provisioning_progress": null, + "provisioning_state": "", + "download_progress": null, + "is_web_accessible": false, + "longitude": "22.5853", + "latitude": "51.2712", + "location": "Lublin, Lublin, Poland", + "custom_longitude": "", + "custom_latitude": "", + "logs_channel": null, + "is_locked_until__date": null, + "is_accessible_by_support_until__date": null, + "created_at": "2019-11-18T12:27:37.423Z", + "is_active": true, + "api_heartbeat_state": "offline", + "__metadata": { + "uri": "/resin/device(@id)?@id=1747415" + } + }, + { + "belongs_to__application": [ + ], + "id": 1747416, + "belongs_to__user": { + "__deferred": { + "uri": "/resin/user(46272)" + }, + "__id": 46272 + }, + "is_managed_by__device": null, + "actor": 4180757, + "should_be_running__release": null, + "device_name": "dashing-spruce", + "device_type": "raspberrypi4-64", + "uuid": "fda508c8583011b8466c26abdd5159f3", + "is_on__commit": "18756d3386c25a044db66b89e0409804", + "note": null, + "local_id": null, + "status": "Idle", + "is_online": false, + "last_connectivity_event": "2019-11-23T00:26:35.074Z", + "is_connected_to_vpn": false, + "last_vpn_event": "2019-11-23T00:26:35.074Z", + "ip_address": "192.168.0.112", + "vpn_address": null, + "public_address": "89.186.29.129", + "os_version": "balenaOS 2.44.0+rev3", + "os_variant": "dev", + "supervisor_version": "10.3.7", + "should_be_managed_by__supervisor_release": null, + "is_managed_by__service_instance": { + "__deferred": { + "uri": "/resin/service_instance(124111)" + }, + "__id": 124111 + }, + "provisioning_progress": null, + "provisioning_state": "", + "download_progress": null, + "is_web_accessible": false, + "longitude": "22.5853", + "latitude": "51.2712", + "location": "Lublin, Lublin, Poland", + "custom_longitude": "", + "custom_latitude": "", + "logs_channel": null, + "is_locked_until__date": null, + "is_accessible_by_support_until__date": null, + "created_at": "2019-11-18T12:27:37.423Z", + "is_active": true, + "api_heartbeat_state": "offline", + "__metadata": { + "uri": "/resin/device(@id)?@id=1747415" + } + } + ] +} diff --git a/tests/commands/device/devices.spec.ts b/tests/commands/device/devices.spec.ts new file mode 100644 index 00000000..ce68f49d --- /dev/null +++ b/tests/commands/device/devices.spec.ts @@ -0,0 +1,77 @@ +import { expect } from 'chai'; +import { BalenaAPIMock } from '../../balena-api-mock'; +import { cleanOutput, runCommand } from '../../helpers'; + +const HELP_RESPONSE = ` +Usage: devices + +Use this command to list all devices that belong to you. + +You can filter the devices by application by using the \`--application\` option. + +Examples: + +\t$ balena devices +\t$ balena devices --application MyApp +\t$ balena devices --app MyApp +\t$ balena devices -a MyApp + +Options: + + --application, -a, --app application name +`; + +describe('balena devices', function() { + let api: BalenaAPIMock; + + beforeEach(() => { + api = new BalenaAPIMock(); + }); + + afterEach(() => { + // Check all expected api calls have been made and clean up. + api.done(); + }); + + it('should print help text with the -h flag', async () => { + api.expectWhoAmI(); + api.expectMixpanel(); + + const { out, err } = await runCommand('devices -h'); + + expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE])); + + expect(err).to.eql([]); + }); + + it('should list devices from own and collaborator apps', async () => { + api.expectWhoAmI(); + api.expectMixpanel(); + + api.scope + .get( + '/v5/device?$orderby=device_name%20asc&$expand=belongs_to__application($select=app_name)', + ) + .replyWithFile(200, __dirname + '/devices.api-response.json', { + 'Content-Type': 'application/json', + }); + + const { out, err } = await runCommand('devices'); + + const lines = cleanOutput(out); + + expect(lines[0].replace(/ +/g, ' ')).to.equal( + 'ID UUID DEVICE NAME DEVICE TYPE APPLICATION NAME STATUS ' + + 'IS ONLINE SUPERVISOR VERSION OS VERSION DASHBOARD URL', + ); + expect(lines).to.have.lengthOf.at.least(2); + + expect(lines.some(l => l.includes('test app'))).to.be.true; + + // Devices with missing applications will have application name set to `N/a`. + // e.g. When user has a device associated with app that user is no longer a collaborator of. + expect(lines.some(l => l.includes('N/a'))).to.be.true; + + expect(err).to.eql([]); + }); +}); diff --git a/tests/commands/device/supported.spec.ts b/tests/commands/device/supported.spec.ts index 8beb2525..0fbdac45 100644 --- a/tests/commands/device/supported.spec.ts +++ b/tests/commands/device/supported.spec.ts @@ -39,9 +39,10 @@ describe('balena devices supported', function() { api.expectWhoAmI(); api.expectMixpanel(); + // TODO: Using the alias api.expect here causes route /config/vars to be called unexpectedly - why? api.scope .get('/device-types/v1') - .replyWithFile(200, __dirname + '/supported.api-response.json', { + .replyWithFile(200, __dirname + '/device-types.api-response.json', { 'Content-Type': 'application/json', }); diff --git a/tests/helpers.ts b/tests/helpers.ts index da34902a..c7613990 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -24,7 +24,9 @@ import * as balenaCLI from '../build/app'; import { configureBluebird, setMaxListeners } from '../build/app-common'; configureBluebird(); -setMaxListeners(25); // it appears that 'nock' adds a bunch of listeners - bug? +setMaxListeners(35); // it appears that 'nock' adds a bunch of listeners - bug? +// SL: Looks like it's not nock causing this, as have seen the problem triggered from help.spec, +// which is not using nock. Perhaps mocha/chai? (unlikely), or something in the CLI? export const runCommand = async (cmd: string) => { const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];