From cc5fe60a15347674992d8ddfb2f5dfe9077b5d70 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Wed, 15 Jan 2020 19:41:47 +0000 Subject: [PATCH 1/3] Add tests for push, deploy and build commands Change-type: patch Signed-off-by: Paulo Castro --- tests/balena-api-mock.ts | 281 +++++++++++------- tests/builder-mock.ts | 60 ++++ tests/commands/app/create.spec.ts | 21 +- tests/commands/build.spec.ts | 110 +++++++ tests/commands/deploy.spec.ts | 131 ++++++++ tests/commands/device/device-move.spec.ts | 25 +- tests/commands/device/device.spec.ts | 51 +++- tests/commands/device/devices.spec.ts | 33 +- tests/commands/device/supported.spec.ts | 34 ++- tests/commands/env/add.spec.ts | 14 +- tests/commands/env/envs.spec.ts | 118 ++++---- tests/commands/env/rename.spec.ts | 6 +- tests/commands/env/rm.spec.ts | 6 +- tests/commands/push.spec.ts | 121 ++++++++ tests/docker-mock.ts | 130 ++++++++ tests/helpers.ts | 95 +++++- tests/nock-mock.ts | 152 ++++++++++ .../application-GET-v5-expanded-app-type.json | 35 +++ .../api-response/device-missing-app.json} | 0 .../api-response/device-types-GET-v1.json} | 0 .../api-response/device.json} | 0 .../api-response/devices.json} | 0 .../test-data/api-response/image-POST-v5.json | 25 ++ .../image-is-part-of-release-POST-v5.json | 19 ++ .../api-response/image-label-POST-v5.json | 15 + .../api-response/release-GET-v5.json | 52 ++++ .../api-response/release-POST-v5.json | 54 ++++ .../builder-response/build-POST-v3.json | 99 ++++++ .../docker-response/images-push-POST.json | 19 ++ .../no-docker-compose/basic/Dockerfile | 4 + .../no-docker-compose/basic/src/start.sh | 2 + 31 files changed, 1493 insertions(+), 219 deletions(-) create mode 100644 tests/builder-mock.ts create mode 100644 tests/commands/build.spec.ts create mode 100644 tests/commands/deploy.spec.ts create mode 100644 tests/commands/push.spec.ts create mode 100644 tests/docker-mock.ts create mode 100644 tests/nock-mock.ts create mode 100644 tests/test-data/api-response/application-GET-v5-expanded-app-type.json rename tests/{commands/device/device.api-response.missing-app.json => test-data/api-response/device-missing-app.json} (100%) rename tests/{commands/device/device-types.api-response.json => test-data/api-response/device-types-GET-v1.json} (100%) rename tests/{commands/device/device.api-response.json => test-data/api-response/device.json} (100%) rename tests/{commands/device/devices.api-response.json => test-data/api-response/devices.json} (100%) create mode 100644 tests/test-data/api-response/image-POST-v5.json create mode 100644 tests/test-data/api-response/image-is-part-of-release-POST-v5.json create mode 100644 tests/test-data/api-response/image-label-POST-v5.json create mode 100644 tests/test-data/api-response/release-GET-v5.json create mode 100644 tests/test-data/api-response/release-POST-v5.json create mode 100644 tests/test-data/builder-response/build-POST-v3.json create mode 100644 tests/test-data/docker-response/images-push-POST.json create mode 100644 tests/test-data/projects/no-docker-compose/basic/Dockerfile create mode 100755 tests/test-data/projects/no-docker-compose/basic/src/start.sh diff --git a/tests/balena-api-mock.ts b/tests/balena-api-mock.ts index 5e1547a0..1cca4d75 100644 --- a/tests/balena-api-mock.ts +++ b/tests/balena-api-mock.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,59 +16,111 @@ */ import * as _ from 'lodash'; -import * as nock from 'nock'; +import * as path from 'path'; -export class BalenaAPIMock { - public static basePathPattern = /api\.balena-cloud\.com/; - public readonly scope: nock.Scope; - // Expose `scope` as `expect` to allow for better semantics in tests - public readonly expect = this.scope; +import { NockMock, ScopeOpts } from './nock-mock'; - // For debugging tests - get unfulfilledCallCount(): number { - return this.scope.pendingMocks().length; - } +const apiResponsePath = path.normalize( + path.join(__dirname, 'test-data', 'api-response'), +); +const jHeader = { 'Content-Type': 'application/json' }; + +export class BalenaAPIMock extends NockMock { constructor() { - nock.cleanAll(); - - if (!nock.isActive()) { - nock.activate(); - } - - this.scope = nock(BalenaAPIMock.basePathPattern); - - nock.emitter.on('no match', this.handleUnexpectedRequest); + super('https://api.balena-cloud.com'); } - public done() { - // scope.done() will throw an error if there are expected api calls that have not happened. - // So ensures that all expected calls have been made. - this.scope.done(); - // Remove 'no match' handler, for tests using nock without this module - nock.emitter.removeListener('no match', this.handleUnexpectedRequest); - // Restore unmocked behavior - nock.cleanAll(); - nock.restore(); + public expectGetApplication(opts: ScopeOpts = {}) { + this.optGet(/^\/v5\/application($|[(?])/, opts).replyWithFile( + 200, + path.join(apiResponsePath, 'application-GET-v5-expanded-app-type.json'), + jHeader, + ); } - public expectTestApp() { - this.scope - .get(/^\/v\d+\/application($|\?)/) - .reply(200, { d: [{ id: 1234567 }] }); + public expectGetMyApplication(opts: ScopeOpts = {}) { + this.optGet(/^\/v5\/my_application($|[(?])/, opts).reply( + 200, + JSON.parse(`{"d": [{ + "user": [{ "username": "bob", "__metadata": {} }], + "id": 1301645, + "__metadata": { "uri": "/resin/my_application(@id)?@id=1301645" }}]} + `), + ); } - public expectTestDevice( - fullUUID = 'f63fd7d7812c34c4c14ae023fdff05f5', - inaccessibleApp = false, - ) { + public expectGetAuth(opts: ScopeOpts = {}) { + this.optGet(/^\/auth\/v1\//, opts).reply(200, { + // "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlJZVFk6TlE3WDpKSDVCOlFFWFk6RkU2TjpLTlVVOklWNTI6TFFRQTo3UjRWOjJVUFI6Qk9ISjpDNklPIn0.eyJqdGkiOiI3ZTNlN2RmMS1iYjljLTQxZTMtOTlkMi00NjVlMjE4YzFmOWQiLCJuYmYiOjE1NzkxOTQ1MjgsImFjY2VzcyI6W3sibmFtZSI6InYyL2MwODljNDIxZmIyMzM2ZDA0NzUxNjZmYmYzZDBmOWZhIiwidHlwZSI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInB1bGwiLCJwdXNoIl19LHsibmFtZSI6InYyLzljMDBjOTQxMzk0MmNkMTVjZmM5MTg5YzVkYWMzNTlkIiwidHlwZSI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInB1bGwiLCJwdXNoIl19XSwiaWF0IjoxNTc5MTk0NTM4LCJleHAiOjE1NzkyMDg5MzgsImF1ZCI6InJlZ2lzdHJ5Mi5iYWxlbmEtY2xvdWQuY29tIiwiaXNzIjoiYXBpLmJhbGVuYS1jbG91ZC5jb20iLCJzdWIiOiJnaF9wYXVsb19jYXN0cm8ifQ.bRw5_lg-nT-c1V4RxIJjujfPuVewZTs0BRNENEw2-sk_6zepLs-sLl9DOSEHYBdi87EtyCiUB3Wqee6fvz2HyQ" + token: 'test', + }); + } + + public expectGetRelease(opts: ScopeOpts = {}) { + this.optGet(/^\/v5\/release($|[(?])/, opts).replyWithFile( + 200, + path.join(apiResponsePath, 'release-GET-v5.json'), + jHeader, + ); + } + + public expectPatchRelease(opts: ScopeOpts = {}) { + this.optPatch(/^\/v5\/release($|[(?])/, opts).reply(200, 'OK'); + } + + public expectPostRelease(opts: ScopeOpts = {}) { + this.optPost(/^\/v5\/release($|[(?])/, opts).replyWithFile( + 200, + path.join(apiResponsePath, 'release-POST-v5.json'), + jHeader, + ); + } + + public expectPatchImage(opts: ScopeOpts = {}) { + this.optPatch(/^\/v5\/image($|[(?])/, opts).reply(200, 'OK'); + } + + public expectPostImage(opts: ScopeOpts = {}) { + this.optPost(/^\/v5\/image($|[(?])/, opts).replyWithFile( + 201, + path.join(apiResponsePath, 'image-POST-v5.json'), + jHeader, + ); + } + + public expectPostImageLabel(opts: ScopeOpts = {}) { + this.optPost(/^\/v5\/image_label($|[(?])/, opts).replyWithFile( + 201, + path.join(apiResponsePath, 'image-label-POST-v5.json'), + jHeader, + ); + } + + public expectPostImageIsPartOfRelease(opts: ScopeOpts = {}) { + this.optPost( + /^\/v5\/image__is_part_of__release($|[(?])/, + opts, + ).replyWithFile( + 200, + path.join(apiResponsePath, 'image-is-part-of-release-POST-v5.json'), + jHeader, + ); + } + + public expectGetDevice(opts: { + fullUUID: string; + inaccessibleApp?: boolean; + optional?: boolean; + persist?: boolean; + }) { const id = 7654321; - this.scope.get(/^\/v\d+\/device($|\?)/).reply(200, { + this.optGet(/^\/v\d+\/device($|\?)/, opts).reply(200, { d: [ { id, - uuid: fullUUID, - belongs_to__application: inaccessibleApp + uuid: opts.fullUUID, + belongs_to__application: opts.inaccessibleApp ? [] : [{ app_name: 'test' }], }, @@ -76,10 +128,10 @@ export class BalenaAPIMock { }); } - public expectAppEnvVars() { - this.scope - .get(/^\/v\d+\/application_environment_variable($|\?)/) - .reply(200, { + public expectGetAppEnvVars(opts: ScopeOpts = {}) { + this.optGet(/^\/v\d+\/application_environment_variable($|\?)/, opts).reply( + 200, + { d: [ { id: 120101, @@ -92,11 +144,12 @@ export class BalenaAPIMock { value: '22', }, ], - }); + }, + ); } - public expectAppConfigVars() { - this.scope.get(/^\/v\d+\/application_config_variable($|\?)/).reply(200, { + public expectGetAppConfigVars(opts: ScopeOpts = {}) { + this.optGet(/^\/v\d+\/application_config_variable($|\?)/, opts).reply(200, { d: [ { id: 120300, @@ -107,10 +160,9 @@ export class BalenaAPIMock { }); } - public expectAppServiceVars() { - this.scope - .get(/^\/v\d+\/service_environment_variable($|\?)/) - .reply(function(uri, _requestBody) { + public expectGetAppServiceVars(opts: ScopeOpts = {}) { + this.optGet(/^\/v\d+\/service_environment_variable($|\?)/, opts).reply( + function(uri, _requestBody) { const match = uri.match(/service_name%20eq%20%27(.+?)%27/); const serviceName = (match && match[1]) || undefined; let varArray: any[]; @@ -121,11 +173,12 @@ export class BalenaAPIMock { varArray = _.map(appServiceVarsByService, value => value); } return [200, { d: varArray }]; - }); + }, + ); } - public expectDeviceEnvVars() { - this.scope.get(/^\/v\d+\/device_environment_variable($|\?)/).reply(200, { + public expectGetDeviceEnvVars(opts: ScopeOpts = {}) { + this.optGet(/^\/v\d+\/device_environment_variable($|\?)/, opts).reply(200, { d: [ { id: 120203, @@ -141,8 +194,8 @@ export class BalenaAPIMock { }); } - public expectDeviceConfigVars() { - this.scope.get(/^\/v\d+\/device_config_variable($|\?)/).reply(200, { + public expectGetDeviceConfigVars(opts: ScopeOpts = {}) { + this.optGet(/^\/v\d+\/device_config_variable($|\?)/, opts).reply(200, { d: [ { id: 120400, @@ -153,25 +206,34 @@ export class BalenaAPIMock { }); } - public expectDeviceServiceVars() { - this.scope - .get(/^\/v\d+\/device_service_environment_variable($|\?)/) - .reply(function(uri, _requestBody) { - const match = uri.match(/service_name%20eq%20%27(.+?)%27/); - const serviceName = (match && match[1]) || undefined; - let varArray: any[]; - if (serviceName) { - const varObj = deviceServiceVarsByService[serviceName]; - varArray = varObj ? [varObj] : []; - } else { - varArray = _.map(deviceServiceVarsByService, value => value); - } - return [200, { d: varArray }]; - }); + public expectGetDeviceServiceVars(opts: ScopeOpts = {}) { + this.optGet( + /^\/v\d+\/device_service_environment_variable($|\?)/, + opts, + ).reply(function(uri, _requestBody) { + const match = uri.match(/service_name%20eq%20%27(.+?)%27/); + const serviceName = (match && match[1]) || undefined; + let varArray: any[]; + if (serviceName) { + const varObj = deviceServiceVarsByService[serviceName]; + varArray = varObj ? [varObj] : []; + } else { + varArray = _.map(deviceServiceVarsByService, value => value); + } + return [200, { d: varArray }]; + }); } - public expectConfigVars() { - this.scope.get('/config/vars').reply(200, { + public expectGetDeviceTypes(opts: ScopeOpts = {}) { + this.optGet('/device-types/v1', opts).replyWithFile( + 200, + path.join(apiResponsePath, 'device-types-GET-v1.json'), + jHeader, + ); + } + + public expectGetConfigVars(opts: ScopeOpts = {}) { + this.optGet('/config/vars', opts).reply(200, { reservedNames: [], reservedNamespaces: [], invalidRegex: '/^d|W/', @@ -182,52 +244,53 @@ export class BalenaAPIMock { }); } - public expectService(serviceName: string, serviceId = 243768) { - this.scope.get(/^\/v\d+\/service($|\?)/).reply(200, { - d: [{ id: serviceId, service_name: serviceName }], + public expectGetService(opts: { + optional?: boolean; + persist?: boolean; + serviceId?: number; + serviceName: string; + }) { + const serviceId = opts.serviceId || 243768; + this.optGet(/^\/v\d+\/service($|\?)/, opts).reply(200, { + d: [{ id: serviceId, service_name: opts.serviceName }], + }); + } + + public expectPostService404(opts: ScopeOpts = {}) { + this.optPost(/^\/v\d+\/service$/, opts).reply( + 404, + 'Unique key constraint violated', + ); + } + + public expectGetUser(opts: ScopeOpts = {}) { + this.optGet(/^\/v5\/user/, opts).reply(200, { + d: [ + { + id: 99999, + actor: 1234567, + username: 'gh_user', + created_at: '2018-08-19T13:55:04.485Z', + __metadata: { + uri: '/resin/user(@id)?@id=43699', + }, + }, + ], }); } // User details are cached in the SDK // so often we don't know if we can expect the whoami request - public expectWhoAmI(persist = false, optional = true) { - const get = (persist ? this.scope.persist() : this.scope).get( - '/user/v1/whoami', - ); - (optional ? get.optionally() : get).reply(200, { + public expectGetWhoAmI(opts: ScopeOpts = {}) { + this.optGet('/user/v1/whoami', opts).reply(200, { id: 99999, - username: 'testuser', + username: 'gh_user', email: 'testuser@test.com', }); } - public expectMixpanel(optional = true) { - const get = this.scope.get(/^\/mixpanel\/track/); - (optional ? get.optionally() : get).reply(200, {}); - } - - protected handleUnexpectedRequest(req: any) { - console.error(`Unexpected http request!: ${req.path}`); - // Errors thrown here are not causing the tests to fail for some reason. - // Possibly due to CLI global error handlers? (error.js) - // (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); - }); + public expectGetMixpanel(opts: ScopeOpts = {}) { + this.optGet(/^\/mixpanel\/track/, opts).reply(200, {}); } } diff --git a/tests/builder-mock.ts b/tests/builder-mock.ts new file mode 100644 index 00000000..86b45ed8 --- /dev/null +++ b/tests/builder-mock.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Bluebird = require('bluebird'); +import * as _ from 'lodash'; +import * as zlib from 'zlib'; + +import { NockMock } from './nock-mock'; + +export class BuilderMock extends NockMock { + constructor() { + super('https://builder.balena-cloud.com'); + } + + public expectPostBuild(opts: { + optional?: boolean; + persist?: boolean; + responseBody: any; + responseCode: number; + checkBuildRequestBody: (requestBody: string | Buffer) => Promise; + }) { + this.optPost(/^\/v3\/build($|[(?])/, opts).reply(async function( + _uri, + requestBody, + callback, + ) { + let error: Error | null = null; + try { + if (typeof requestBody === 'string') { + const gzipped = Buffer.from(requestBody, 'hex'); + const gunzipped = await Bluebird.fromCallback(cb => { + zlib.gunzip(gzipped, cb); + }); + await opts.checkBuildRequestBody(gunzipped); + } else { + throw new Error( + `unexpected requestBody type "${typeof requestBody}"`, + ); + } + } catch (err) { + error = err; + } + callback(error, [opts.responseCode, opts.responseBody]); + }); + } +} diff --git a/tests/commands/app/create.spec.ts b/tests/commands/app/create.spec.ts index 25780a21..539856ae 100644 --- a/tests/commands/app/create.spec.ts +++ b/tests/commands/app/create.spec.ts @@ -1,3 +1,20 @@ +/** + * @license + * Copyright 2019-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { expect } from 'chai'; import { BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; @@ -37,8 +54,8 @@ describe('balena app create', function() { }); it('should print help text with the -h flag', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('app create -h'); diff --git a/tests/commands/build.spec.ts b/tests/commands/build.spec.ts new file mode 100644 index 00000000..919a2715 --- /dev/null +++ b/tests/commands/build.spec.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configureBluebird } from '../../build/app-common'; + +configureBluebird(); + +import { expect } from 'chai'; +import { stripIndent } from 'common-tags'; +import * as path from 'path'; + +import { BalenaAPIMock } from '../balena-api-mock'; +import { DockerMock } from '../docker-mock'; +import { + cleanOutput, + inspectTarStream, + runCommand, + TarStreamFiles, +} from '../helpers'; + +const repoPath = path.normalize(path.join(__dirname, '..', '..')); +const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); + +describe('balena build', function() { + let api: BalenaAPIMock; + let docker: DockerMock; + + this.beforeEach(() => { + api = new BalenaAPIMock(); + docker = new DockerMock(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); + docker.expectGetPing(); + docker.expectGetInfo(); + docker.expectGetVersion(); + docker.expectGetImages(); + }); + + this.afterEach(() => { + // Check all expected api calls have been made and clean up. + api.done(); + docker.done(); + }); + + it('should create the expected tar stream', async () => { + const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); + const expectedFiles: TarStreamFiles = { + 'src/start.sh': { fileSize: 89, type: 'file' }, + Dockerfile: { fileSize: 85, type: 'file' }, + }; + const responseBody = stripIndent` + {"stream":"Step 1/4 : FROM busybox"} + {"stream":"\\n"} + {"stream":" ---\\u003e 64f5d945efcc\\n"} + {"stream":"Step 2/4 : COPY ./src/start.sh /start.sh"} + {"stream":"\\n"} + {"stream":" ---\\u003e Using cache\\n"} + {"stream":" ---\\u003e 97098fc9d757\\n"} + {"stream":"Step 3/4 : RUN chmod a+x /start.sh"} + {"stream":"\\n"} + {"stream":" ---\\u003e Using cache\\n"} + {"stream":" ---\\u003e 33728e2e3f7e\\n"} + {"stream":"Step 4/4 : CMD [\\"/start.sh\\"]"} + {"stream":"\\n"} + {"stream":" ---\\u003e Using cache\\n"} + {"stream":" ---\\u003e 2590e3b11eaf\\n"} + {"aux":{"ID":"sha256:2590e3b11eaf739491235016b53fec5d209c81837160abdd267c8fe5005ff1bd"}} + {"stream":"Successfully built 2590e3b11eaf\\n"} + {"stream":"Successfully tagged basic_main:latest\\n"}`; + + docker.expectPostBuild({ + tag: 'basic_main', + responseCode: 200, + responseBody, + checkBuildRequestBody: (buildRequestBody: string) => + inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), + }); + + const { out, err } = await runCommand( + `build ${projectPath} --deviceType nuc --arch amd64`, + ); + + expect(err).to.have.members([]); + expect( + cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), + ).to.include.members([ + `[Info] Creating default composition with source: ${projectPath}`, + '[Info] Building for amd64/nuc', + '[Info] Docker Desktop detected (daemon architecture: "x86_64")', + '[Info] Docker itself will determine and enable architecture emulation if required,', + '[Info] without balena-cli intervention and regardless of the --emulated option.', + '[Build] main Image size: 1.14 MB', + '[Success] Build succeeded!', + ]); + }); +}); diff --git a/tests/commands/deploy.spec.ts b/tests/commands/deploy.spec.ts new file mode 100644 index 00000000..9b67b3ec --- /dev/null +++ b/tests/commands/deploy.spec.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configureBluebird } from '../../build/app-common'; + +configureBluebird(); + +import { expect } from 'chai'; +import { stripIndent } from 'common-tags'; +import * as path from 'path'; + +import { BalenaAPIMock } from '../balena-api-mock'; +import { DockerMock } from '../docker-mock'; +import { + cleanOutput, + inspectTarStream, + runCommand, + TarStreamFiles, +} from '../helpers'; + +const repoPath = path.normalize(path.join(__dirname, '..', '..')); +const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); + +describe('balena deploy', function() { + let api: BalenaAPIMock; + let docker: DockerMock; + + this.beforeEach(() => { + api = new BalenaAPIMock(); + docker = new DockerMock(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); + api.expectGetDeviceTypes(); + api.expectGetApplication(); + api.expectPatchRelease(); + api.expectPostRelease(); + api.expectGetRelease(); + api.expectGetUser(); + api.expectGetService({ serviceName: 'main' }); + api.expectPostService404(); + api.expectGetAuth(); + api.expectPostImage(); + api.expectPostImageIsPartOfRelease(); + api.expectPostImageLabel(); + api.expectPatchImage(); + + docker.expectGetPing(); + docker.expectGetInfo(); + docker.expectGetVersion(); + docker.expectGetImages({ persist: true }); + docker.expectPostImagesTag(); + docker.expectPostImagesPush(); + docker.expectDeleteImages(); + }); + + this.afterEach(() => { + // Check all expected api calls have been made and clean up. + api.done(); + docker.done(); + }); + + it('should create the expected --build tar stream', async () => { + const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); + const expectedFiles: TarStreamFiles = { + 'src/start.sh': { fileSize: 89, type: 'file' }, + Dockerfile: { fileSize: 85, type: 'file' }, + }; + const responseBody = stripIndent` + {"stream":"Step 1/4 : FROM busybox"} + {"stream":"\\n"} + {"stream":" ---\\u003e 64f5d945efcc\\n"} + {"stream":"Step 2/4 : COPY ./src/start.sh /start.sh"} + {"stream":"\\n"} + {"stream":" ---\\u003e Using cache\\n"} + {"stream":" ---\\u003e 97098fc9d757\\n"} + {"stream":"Step 3/4 : RUN chmod a+x /start.sh"} + {"stream":"\\n"} + {"stream":" ---\\u003e Using cache\\n"} + {"stream":" ---\\u003e 33728e2e3f7e\\n"} + {"stream":"Step 4/4 : CMD [\\"/start.sh\\"]"} + {"stream":"\\n"} + {"stream":" ---\\u003e Using cache\\n"} + {"stream":" ---\\u003e 2590e3b11eaf\\n"} + {"aux":{"ID":"sha256:2590e3b11eaf739491235016b53fec5d209c81837160abdd267c8fe5005ff1bd"}} + {"stream":"Successfully built 2590e3b11eaf\\n"} + {"stream":"Successfully tagged basic_main:latest\\n"}`; + + docker.expectPostBuild({ + tag: 'basic_main', + responseCode: 200, + responseBody, + checkBuildRequestBody: (buildRequestBody: string) => + inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), + }); + + const { out, err } = await runCommand( + `deploy testApp --build --source ${projectPath}`, + ); + + expect(err).to.have.members([]); + expect( + cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), + ).to.include.members([ + `[Info] Creating default composition with source: ${projectPath}`, + '[Info] Building for armv7hf/raspberrypi3', + '[Info] Docker Desktop detected (daemon architecture: "x86_64")', + '[Info] Docker itself will determine and enable architecture emulation if required,', + '[Info] without balena-cli intervention and regardless of the --emulated option.', + '[Build] main Image size: 1.14 MB', + '[Info] Creating release...', + '[Info] Pushing images to registry...', + '[Info] Saving release...', + '[Success] Deploy succeeded!', + '[Success] Release: 09f7c3e1fdec609be818002299edfc2a', + ]); + }); +}); diff --git a/tests/commands/device/device-move.spec.ts b/tests/commands/device/device-move.spec.ts index ebdbd2a6..dc761521 100644 --- a/tests/commands/device/device-move.spec.ts +++ b/tests/commands/device/device-move.spec.ts @@ -1,3 +1,20 @@ +/** + * @license + * Copyright 2019-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { expect } from 'chai'; import { BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; @@ -32,8 +49,8 @@ describe('balena device move', function() { }); it('should print help text with the -h flag', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device move -h'); @@ -45,8 +62,8 @@ describe('balena device move', function() { 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(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device move'); const errLines = cleanOutput(err); diff --git a/tests/commands/device/device.spec.ts b/tests/commands/device/device.spec.ts index ad9a117b..789a5728 100644 --- a/tests/commands/device/device.spec.ts +++ b/tests/commands/device/device.spec.ts @@ -1,4 +1,23 @@ +/** + * @license + * Copyright 2019-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { expect } from 'chai'; +import * as path from 'path'; + import { BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; @@ -12,6 +31,10 @@ Examples: \t$ balena device 7cf02a6 `; +const apiResponsePath = path.normalize( + path.join(__dirname, '..', '..', 'test-data', 'api-response'), +); + describe('balena device', function() { let api: BalenaAPIMock; @@ -25,8 +48,8 @@ describe('balena device', function() { }); it('should print help text with the -h flag', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device -h'); @@ -38,8 +61,8 @@ describe('balena device', function() { 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(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device'); const errLines = cleanOutput(err); @@ -49,12 +72,12 @@ describe('balena device', function() { }); it('should list device details for provided uuid', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); api.scope .get(/^\/v5\/device/) - .replyWithFile(200, __dirname + '/device.api-response.json', { + .replyWithFile(200, path.join(apiResponsePath, 'device.json'), { 'Content-Type': 'application/json', }); @@ -72,14 +95,18 @@ describe('balena device', function() { 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.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); api.scope .get(/^\/v5\/device/) - .replyWithFile(200, __dirname + '/device.api-response.missing-app.json', { - 'Content-Type': 'application/json', - }); + .replyWithFile( + 200, + path.join(apiResponsePath, 'device-missing-app.json'), + { + 'Content-Type': 'application/json', + }, + ); const { out, err } = await runCommand('device 27fda508c'); diff --git a/tests/commands/device/devices.spec.ts b/tests/commands/device/devices.spec.ts index ce68f49d..5499ec46 100644 --- a/tests/commands/device/devices.spec.ts +++ b/tests/commands/device/devices.spec.ts @@ -1,4 +1,23 @@ +/** + * @license + * Copyright 2019-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { expect } from 'chai'; +import * as path from 'path'; + import { BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; @@ -21,6 +40,10 @@ Options: --application, -a, --app application name `; +const apiResponsePath = path.normalize( + path.join(__dirname, '..', '..', 'test-data', 'api-response'), +); + describe('balena devices', function() { let api: BalenaAPIMock; @@ -34,8 +57,8 @@ describe('balena devices', function() { }); it('should print help text with the -h flag', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('devices -h'); @@ -45,14 +68,14 @@ describe('balena devices', function() { }); it('should list devices from own and collaborator apps', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); api.scope .get( '/v5/device?$orderby=device_name%20asc&$expand=belongs_to__application($select=app_name)', ) - .replyWithFile(200, __dirname + '/devices.api-response.json', { + .replyWithFile(200, path.join(apiResponsePath, 'devices.json'), { 'Content-Type': 'application/json', }); diff --git a/tests/commands/device/supported.spec.ts b/tests/commands/device/supported.spec.ts index a2487586..01aaa7b8 100644 --- a/tests/commands/device/supported.spec.ts +++ b/tests/commands/device/supported.spec.ts @@ -1,4 +1,22 @@ +/** + * @license + * Copyright 2019-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { expect } from 'chai'; + import { BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; @@ -15,8 +33,8 @@ describe('balena devices supported', function() { }); it('should print help text with the -h flag', async () => { - api.expectWhoAmI(); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('devices supported -h'); @@ -26,15 +44,9 @@ describe('balena devices supported', function() { }); it('should list currently supported devices, with correct filtering', async () => { - 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 + '/device-types.api-response.json', { - 'Content-Type': 'application/json', - }); + api.expectGetWhoAmI({ optional: true }); + api.expectGetMixpanel({ optional: true }); + api.expectGetDeviceTypes(); const { out, err } = await runCommand('devices supported'); diff --git a/tests/commands/env/add.spec.ts b/tests/commands/env/add.spec.ts index 3e942d64..87ed7ddf 100644 --- a/tests/commands/env/add.spec.ts +++ b/tests/commands/env/add.spec.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,8 @@ describe('balena env add', function() { beforeEach(() => { api = new BalenaAPIMock(); - api.expectWhoAmI(true); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); }); afterEach(() => { @@ -35,14 +35,14 @@ describe('balena env add', function() { }); it('should successfully add an environment variable', async () => { - const deviceId = 'f63fd7d7812c34c4c14ae023fdff05f5'; - api.expectTestDevice(); - api.expectConfigVars(); + const fullUUID = 'f63fd7d7812c34c4c14ae023fdff05f5'; + api.expectGetDevice({ fullUUID }); + api.expectGetConfigVars(); api.scope .post(/^\/v\d+\/device_environment_variable($|\?)/) .reply(200, 'OK'); - const { out, err } = await runCommand(`env add TEST 1 -d ${deviceId}`); + const { out, err } = await runCommand(`env add TEST 1 -d ${fullUUID}`); expect(out.join('')).to.equal(''); expect(err.join('')).to.equal(''); diff --git a/tests/commands/env/envs.spec.ts b/tests/commands/env/envs.spec.ts index 6753e899..c3f4dc92 100644 --- a/tests/commands/env/envs.spec.ts +++ b/tests/commands/env/envs.spec.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,8 +29,8 @@ describe('balena envs', function() { beforeEach(() => { api = new BalenaAPIMock(); - api.expectWhoAmI(true); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); // Random device UUID used to frustrate _.memoize() in utils/cloud.ts fullUUID = require('crypto') .randomBytes(16) @@ -44,8 +44,8 @@ describe('balena envs', function() { }); it('should successfully list env vars for a test app', async () => { - api.expectTestApp(); - api.expectAppEnvVars(); + api.expectGetApplication(); + api.expectGetAppEnvVars(); const { out, err } = await runCommand(`envs -a ${appName}`); @@ -60,8 +60,8 @@ describe('balena envs', function() { }); it('should successfully list config vars for a test app', async () => { - api.expectTestApp(); - api.expectAppConfigVars(); + api.expectGetApplication(); + api.expectGetAppConfigVars(); const { out, err } = await runCommand(`envs -a ${appName} --config`); @@ -75,8 +75,8 @@ describe('balena envs', function() { }); it('should successfully list config vars for a test app (JSON output)', async () => { - api.expectTestApp(); - api.expectAppConfigVars(); + api.expectGetApplication(); + api.expectGetAppConfigVars(); const { out, err } = await runCommand(`envs -cja ${appName}`); @@ -92,9 +92,9 @@ describe('balena envs', function() { it('should successfully list service variables for a test app (-s flag)', async () => { const serviceName = 'service2'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectAppServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetAppServiceVars(); const { out, err } = await runCommand( `envs -a ${appName} -s ${serviceName}`, @@ -111,9 +111,9 @@ describe('balena envs', function() { it('should produce an empty JSON array when no app service variables exist', async () => { const serviceName = 'nono'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectAppServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetAppServiceVars(); const { out, err } = await runCommand( `envs -a ${appName} -s ${serviceName} -j`, @@ -124,9 +124,9 @@ describe('balena envs', function() { }); it('should successfully list env and service vars for a test app (--all flag)', async () => { - api.expectTestApp(); - api.expectAppEnvVars(); - api.expectAppServiceVars(); + api.expectGetApplication(); + api.expectGetAppEnvVars(); + api.expectGetAppServiceVars(); const { out, err } = await runCommand(`envs -a ${appName} --all`); @@ -144,10 +144,10 @@ describe('balena envs', function() { it('should successfully list env and service vars for a test app (--all -s flags)', async () => { const serviceName = 'service1'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectAppEnvVars(); - api.expectAppServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetAppEnvVars(); + api.expectGetAppServiceVars(); const { out, err } = await runCommand( `envs -a ${appName} --all -s ${serviceName}`, @@ -165,8 +165,8 @@ describe('balena envs', function() { }); it('should successfully list env variables for a test device', async () => { - api.expectTestDevice(fullUUID); - api.expectDeviceEnvVars(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceEnvVars(); const { out, err } = await runCommand(`envs -d ${shortUUID}`); @@ -181,8 +181,8 @@ describe('balena envs', function() { }); it('should successfully list env variables for a test device (JSON output)', async () => { - api.expectTestDevice(fullUUID); - api.expectDeviceEnvVars(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceEnvVars(); const { out, err } = await runCommand(`envs -jd ${shortUUID}`); @@ -202,8 +202,8 @@ describe('balena envs', function() { }); it('should successfully list config variables for a test device', async () => { - api.expectTestDevice(fullUUID); - api.expectDeviceConfigVars(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceConfigVars(); const { out, err } = await runCommand(`envs -d ${shortUUID} --config`); @@ -218,10 +218,10 @@ describe('balena envs', function() { it('should successfully list service variables for a test device (-s flag)', async () => { const serviceName = 'service2'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectTestDevice(fullUUID); - api.expectDeviceServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceServiceVars(); const { out, err } = await runCommand( `envs -d ${shortUUID} -s ${serviceName}`, @@ -238,10 +238,10 @@ describe('balena envs', function() { it('should produce an empty JSON array when no device service variables exist', async () => { const serviceName = 'nono'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectTestDevice(fullUUID); - api.expectDeviceServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceServiceVars(); const { out, err } = await runCommand( `envs -d ${shortUUID} -s ${serviceName} -j`, @@ -252,12 +252,12 @@ describe('balena envs', function() { }); it('should successfully list env and service variables for a test device (--all flag)', async () => { - api.expectTestApp(); - api.expectAppEnvVars(); - api.expectAppServiceVars(); - api.expectTestDevice(fullUUID); - api.expectDeviceEnvVars(); - api.expectDeviceServiceVars(); + api.expectGetApplication(); + api.expectGetAppEnvVars(); + api.expectGetAppServiceVars(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceEnvVars(); + api.expectGetDeviceServiceVars(); const uuid = shortUUID; const { out, err } = await runCommand(`envs -d ${uuid} --all`); @@ -279,9 +279,9 @@ describe('balena envs', function() { }); it('should successfully list env and service variables for a test device (unknown app)', async () => { - api.expectTestDevice(fullUUID, true); - api.expectDeviceEnvVars(); - api.expectDeviceServiceVars(); + api.expectGetDevice({ fullUUID, inaccessibleApp: true }); + api.expectGetDeviceEnvVars(); + api.expectGetDeviceServiceVars(); const uuid = shortUUID; const { out, err } = await runCommand(`envs -d ${uuid} --all`); @@ -300,13 +300,13 @@ describe('balena envs', function() { it('should successfully list env and service vars for a test device (--all -s flags)', async () => { const serviceName = 'service1'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectAppEnvVars(); - api.expectAppServiceVars(); - api.expectTestDevice(fullUUID); - api.expectDeviceEnvVars(); - api.expectDeviceServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetAppEnvVars(); + api.expectGetAppServiceVars(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceEnvVars(); + api.expectGetDeviceServiceVars(); const uuid = shortUUID; const { out, err } = await runCommand( @@ -329,13 +329,13 @@ describe('balena envs', function() { it('should successfully list env and service vars for a test device (--all -js flags)', async () => { const serviceName = 'service1'; - api.expectService(serviceName); - api.expectTestApp(); - api.expectAppEnvVars(); - api.expectAppServiceVars(); - api.expectTestDevice(fullUUID); - api.expectDeviceEnvVars(); - api.expectDeviceServiceVars(); + api.expectGetService({ serviceName }); + api.expectGetApplication(); + api.expectGetAppEnvVars(); + api.expectGetAppServiceVars(); + api.expectGetDevice({ fullUUID }); + api.expectGetDeviceEnvVars(); + api.expectGetDeviceServiceVars(); const { out, err } = await runCommand( `envs -d ${shortUUID} --all -js ${serviceName}`, diff --git a/tests/commands/env/rename.spec.ts b/tests/commands/env/rename.spec.ts index 5fb2cd07..27d632e9 100644 --- a/tests/commands/env/rename.spec.ts +++ b/tests/commands/env/rename.spec.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2016-2019 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,8 @@ describe('balena env rename', function() { beforeEach(() => { api = new BalenaAPIMock(); - api.expectWhoAmI(true); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); }); afterEach(() => { diff --git a/tests/commands/env/rm.spec.ts b/tests/commands/env/rm.spec.ts index 52d08c2f..e2e9b4bc 100644 --- a/tests/commands/env/rm.spec.ts +++ b/tests/commands/env/rm.spec.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2016-2019 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,8 @@ describe('balena env rm', function() { beforeEach(() => { api = new BalenaAPIMock(); - api.expectWhoAmI(true); - api.expectMixpanel(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); }); afterEach(() => { diff --git a/tests/commands/push.spec.ts b/tests/commands/push.spec.ts new file mode 100644 index 00000000..1c536ec7 --- /dev/null +++ b/tests/commands/push.spec.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configureBluebird } from '../../build/app-common'; + +configureBluebird(); + +import { expect } from 'chai'; +import { fs } from 'mz'; +import * as path from 'path'; + +import { BalenaAPIMock } from '../balena-api-mock'; +import { BuilderMock } from '../builder-mock'; +// import { DockerMock } from '../docker-mock'; +import { + cleanOutput, + inspectTarStream, + runCommand, + TarStreamFiles, +} from '../helpers'; + +const repoPath = path.normalize(path.join(__dirname, '..', '..')); +const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); +const builderResponsePath = path.normalize( + path.join(__dirname, '..', 'test-data', 'builder-response'), +); + +describe('balena push', function() { + let api: BalenaAPIMock; + let builder: BuilderMock; + + this.beforeEach(() => { + api = new BalenaAPIMock(); + builder = new BuilderMock(); + api.expectGetWhoAmI({ optional: true, persist: true }); + api.expectGetMixpanel({ optional: true }); + api.expectGetMyApplication(); + }); + + this.afterEach(() => { + // Check all expected api calls have been made and clean up. + api.done(); + builder.done(); + }); + + it('should create the expected tar stream', async () => { + const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); + const expectedFiles: TarStreamFiles = { + 'src/start.sh': { fileSize: 89, type: 'file' }, + Dockerfile: { fileSize: 85, type: 'file' }, + }; + const responseBody = await fs.readFile( + path.join(builderResponsePath, 'build-POST-v3.json'), + 'utf8', + ); + + builder.expectPostBuild({ + responseCode: 200, + responseBody, + checkBuildRequestBody: (buildRequestBody: string | Buffer) => + inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), + }); + + const { out, err } = await runCommand( + `push testApp --source ${projectPath}`, + ); + + expect(err).to.have.members([]); + expect( + cleanOutput(out).map(line => + line + .replace(/\s{2,}/g, ' ') + .replace(/in \d+? seconds/, 'in 20 seconds'), + ), + ).to.include.members([ + '[Info] Starting build for testApp, user gh_user', + '[Info] Dashboard link: https://dashboard.balena-cloud.com/apps/1301645/devices', + '[Info] Building on arm01', + '[Info] Pulling previous images for caching purposes...', + '[Success] Successfully pulled cache images', + '[main] Step 1/4 : FROM busybox', + '[main] ---> 76aea0766768', + '[main] Step 2/4 : COPY ./src/start.sh /start.sh', + '[main] ---> b563ad6a0801', + '[main] Step 3/4 : RUN chmod a+x /start.sh', + '[main] ---> Running in 10d4ddc40bfc', + '[main] Removing intermediate container 10d4ddc40bfc', + '[main] ---> 82e98871a32c', + '[main] Step 4/4 : CMD ["/start.sh"]', + '[main] ---> Running in 0682894e13eb', + '[main] Removing intermediate container 0682894e13eb', + '[main] ---> 889ccb6afc7c', + '[main] Successfully built 889ccb6afc7c', + '[Info] Uploading images', + '[Success] Successfully uploaded images', + '[Info] Built on arm01', + '[Success] Release successfully created!', + '[Info] Release: 05a24b5b034c9f95f25d4d74f0593bea (id: 1220245)', + '[Info] ┌─────────┬────────────┬────────────┐', + '[Info] │ Service │ Image Size │ Build Time │', + '[Info] ├─────────┼────────────┼────────────┤', + '[Info] │ main │ 1.32 MB │ 11 seconds │', + '[Info] └─────────┴────────────┴────────────┘', + '[Info] Build finished in 20 seconds', + ]); + }); +}); diff --git a/tests/docker-mock.ts b/tests/docker-mock.ts new file mode 100644 index 00000000..f49eb2e8 --- /dev/null +++ b/tests/docker-mock.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as _ from 'lodash'; +import * as path from 'path'; + +import { NockMock, ScopeOpts } from './nock-mock'; + +const dockerResponsePath = path.normalize( + path.join(__dirname, 'test-data', 'docker-response'), +); + +export class DockerMock extends NockMock { + constructor() { + super('http://localhost'); + } + + public expectGetPing(opts: ScopeOpts = {}) { + this.optGet('/_ping', opts).reply(200, 'OK'); + } + + public expectGetInfo(opts: ScopeOpts = {}) { + // this body is a partial copy from Docker for Mac v18.06.1-ce-mac73 + const body = { + KernelVersion: '4.9.93-linuxkit-aufs', + OperatingSystem: 'Docker for Mac', + OSType: 'linux', + Architecture: 'x86_64', + }; + this.optGet('/info', opts).reply(200, body); + } + + public expectGetVersion(opts: ScopeOpts = {}) { + // this body is partial copy from Docker for Mac v18.06.1-ce-mac73 + const body = { + Platform: { + Name: '', + }, + Version: '18.06.1-ce', + ApiVersion: '1.38', + MinAPIVersion: '1.12', + GitCommit: 'e68fc7a', + GoVersion: 'go1.10.3', + Os: 'linux', + Arch: 'amd64', + KernelVersion: '4.9.93-linuxkit-aufs', + Experimental: true, + BuildTime: '2018-08-21T17:29:02.000000000+00:00', + }; + this.optGet('/version', opts).reply(200, body); + } + + public expectPostBuild(opts: { + optional?: boolean; + persist?: boolean; + responseBody: any; + responseCode: number; + tag: string; + checkBuildRequestBody: (requestBody: string) => Promise; + }) { + this.optPost( + new RegExp(`^/build\\?t=${_.escapeRegExp(opts.tag)}&`), + opts, + ).reply(async function(_uri, requestBody, cb) { + let error: Error | null = null; + try { + if (typeof requestBody === 'string') { + await opts.checkBuildRequestBody(requestBody); + } else { + throw new Error( + `unexpected requestBody type "${typeof requestBody}"`, + ); + } + } catch (err) { + error = err; + } + cb(error, [opts.responseCode, opts.responseBody]); + }); + } + + public expectGetImages(opts: ScopeOpts = {}) { + // this body is partial copy from Docker for Mac v18.06.1-ce-mac73 + const body = { + Size: 1199596, + }; + this.optGet(/^\/images\//, opts).reply(200, body); + } + + public expectDeleteImages(opts: ScopeOpts = {}) { + this.optDelete(/^\/images\//, opts).reply(200, [ + { + Untagged: + 'registry2.balena-cloud.com/v2/c089c421fb2336d0475166fbf3d0f9fa:latest', + }, + { + Untagged: + 'registry2.balena-cloud.com/v2/c089c421fb2336d0475166fbf3d0f9fa@sha256:444a5e0c57eed51f5e752b908cb95188c25a0476fc6e5f43e5113edfc4d07199', + }, + ]); + } + + public expectPostImagesTag(opts: ScopeOpts = {}) { + this.optPost(/^\/images\/.+?\/tag\?/, opts).reply(201); + } + + public expectPostImagesPush(opts: ScopeOpts = {}) { + this.optPost(/^\/images\/.+?\/push/, opts).replyWithFile( + 200, + path.join(dockerResponsePath, 'images-push-POST.json'), + { + 'api-version': '1.38', + 'Content-Type': 'application/json', + }, + ); + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index c7613990..a325cd8b 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -17,8 +17,13 @@ import intercept = require('intercept-stdout'); import * as _ from 'lodash'; +import { fs } from 'mz'; import * as nock from 'nock'; import * as path from 'path'; +import { PathUtils } from 'resin-multibuild'; +import { Readable } from 'stream'; +import * as tar from 'tar-stream'; +import { streamToBuffer } from 'tar-utils'; import * as balenaCLI from '../build/app'; import { configureBluebird, setMaxListeners } from '../build/app-common'; @@ -44,7 +49,7 @@ export const runCommand = async (cmd: string) => { // Skip over debug messages if ( typeof log === 'string' && - !log.startsWith('[debug]') && + !log.match(/\[debug\]/i) && // TODO stop this warning message from appearing when running // sdk.setSharedOptions multiple times in the same process !log.startsWith('Shared SDK options') && @@ -87,14 +92,96 @@ export const balenaAPIMock = () => { }); }; -export function cleanOutput(output: string[] | string) { +export function cleanOutput(output: string[] | string): string[] { return _(_.castArray(output)) - .map(log => { + .map((log: string) => { return log.split('\n').map(line => { - return line.trim(); + return monochrome(line.trim()); }); }) .flatten() .compact() .value(); } + +/** + * Remove text colors (ASCII escape sequences). Example: + * Input: '\u001b[2K\r\u001b[34m[Build]\u001b[39m \u001b[1mmain\u001b[22m Image size: 1.14 MB' + * Output: '[Build] main Image size: 1.14 MB' + * + * TODO: check this function against a spec (ASCII escape sequences). It was + * coded from observation of a few samples only, and may not cover all cases. + */ +export function monochrome(text: string): string { + return text.replace(/\u001b\[\??\d+?[a-zA-Z]\r?/g, ''); +} + +export interface TarStreamFiles { + [filePath: string]: { + fileSize: number; + type: tar.Headers['type']; + }; +} + +/** + * Run a few chai.expect() test assertions on a tar stream/buffer produced by + * the balena push, build and deploy commands, intercepted at HTTP level on + * their way from the CLI to the Docker daemon or balenaCloud builders. + * + * @param tarRequestBody Intercepted buffer of tar stream to be sent to builders/Docker + * @param expectedFiles Details of files expected to be found in the buffer + * @param projectPath Path of test project that was tarred, to compare file contents + * @param expect chai.expect function + */ +export async function inspectTarStream( + tarRequestBody: string | Buffer, + expectedFiles: TarStreamFiles, + projectPath: string, + expect: Chai.ExpectStatic, +): Promise { + // string to stream: https://stackoverflow.com/a/22085851 + const sourceTarStream = new Readable(); + sourceTarStream._read = () => undefined; + sourceTarStream.push(tarRequestBody); + sourceTarStream.push(null); + + const found: TarStreamFiles = await new Promise((resolve, reject) => { + const foundFiles: TarStreamFiles = {}; + const extract = tar.extract(); + extract.on('error', reject); + extract.on( + 'entry', + async (header: tar.Headers, stream: Readable, next: tar.Callback) => { + try { + // TODO: test the .balena folder instead of ignoring it + if (header.name.startsWith('.balena/')) { + stream.resume(); + } else { + expect(foundFiles).to.not.have.property(header.name); + foundFiles[header.name] = { + fileSize: header.size || 0, + type: header.type, + }; + const [buf, buf2] = await Promise.all([ + streamToBuffer(stream), + fs.readFile( + path.join(projectPath, PathUtils.toNativePath(header.name)), + ), + ]); + expect(buf.equals(buf2)).to.be.true; + } + } catch (err) { + reject(err); + } + next(); + }, + ); + extract.once('finish', () => { + resolve(foundFiles); + }); + sourceTarStream.on('error', reject); + sourceTarStream.pipe(extract); + }); + + expect(found).to.deep.equal(expectedFiles); +} diff --git a/tests/nock-mock.ts b/tests/nock-mock.ts new file mode 100644 index 00000000..7659e610 --- /dev/null +++ b/tests/nock-mock.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configureBluebird } from '../build/app-common'; + +configureBluebird(); + +import * as _ from 'lodash'; +import * as nock from 'nock'; + +export interface ScopeOpts { + optional?: boolean; + persist?: boolean; +} + +/** + * Base class for tests using nock to intercept HTTP requests. + * Subclasses include BalenaAPIMock, DockerMock and BuilderMock. + */ +export class NockMock { + public readonly scope: nock.Scope; + // Expose `scope` as `expect` to allow for better semantics in tests + public readonly expect = this.scope; + protected static instanceCount = 0; + + constructor(public basePathPattern: string | RegExp) { + if (NockMock.instanceCount === 0) { + if (!nock.isActive()) { + nock.activate(); + } + nock.emitter.on('no match', this.handleUnexpectedRequest); + } else if (process.env.DEBUG) { + console.error( + `[debug] NockMock.constructor() instance count is ${ + NockMock.instanceCount + }`, + ); + } + NockMock.instanceCount += 1; + this.scope = nock(this.basePathPattern); + } + + public optGet( + uri: string | RegExp | ((uri: string) => boolean), + opts: ScopeOpts, + ): nock.Interceptor { + opts = _.assign({ optional: false, persist: false }, opts); + const get = (opts.persist ? this.scope.persist() : this.scope).get(uri); + return opts.optional ? get.optionally() : get; + } + + public optDelete( + uri: string | RegExp | ((uri: string) => boolean), + opts: ScopeOpts, + ) { + opts = _.assign({ optional: false, persist: false }, opts); + const del = (opts.persist ? this.scope.persist() : this.scope).delete(uri); + return opts.optional ? del.optionally() : del; + } + + public optPatch( + uri: string | RegExp | ((uri: string) => boolean), + opts: ScopeOpts, + ) { + opts = _.assign({ optional: false, persist: false }, opts); + const patch = (opts.persist ? this.scope.persist() : this.scope).patch(uri); + return opts.optional ? patch.optionally() : patch; + } + + public optPost( + uri: string | RegExp | ((uri: string) => boolean), + opts: ScopeOpts, + ) { + opts = _.assign({ optional: false, persist: false }, opts); + const post = (opts.persist ? this.scope.persist() : this.scope).post(uri); + return opts.optional ? post.optionally() : post; + } + + public done() { + try { + // scope.done() will throw an error if there are expected api calls that have not happened. + // So ensure that all expected calls have been made. + this.scope.done(); + } finally { + const count = NockMock.instanceCount - 1; + if (count < 0 && process.env.DEBUG) { + console.error( + `[debug] Warning: NockMock.instanceCount is negative (${count})`, + ); + } + NockMock.instanceCount = Math.max(0, count); + if (NockMock.instanceCount === 0) { + // Remove 'no match' handler, for tests using nock without this module + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + nock.restore(); + } else if (process.env.DEBUG) { + console.error( + `[debug] NockMock.done() instance count is ${NockMock.instanceCount}`, + ); + } + } + } + + protected handleUnexpectedRequest(req: any) { + const o = req.options || {}; + const u = o.uri || {}; + console.error( + `Unexpected http request!: ${req.method} ${o.proto || + u.protocol}//${o.host || u.host}${req.path || o.path || u.path}`, + ); + // Errors thrown here are not causing the tests to fail for some reason. + // Possibly due to CLI global error handlers? (error.js) + // (Also, nock should automatically throw an error, but also not happening) + // For now, the console.error is sufficient (will fail the test) + } + + // For debugging tests + get unfulfilledCallCount(): number { + return this.scope.pendingMocks().length; + } + + 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); + }); + } +} diff --git a/tests/test-data/api-response/application-GET-v5-expanded-app-type.json b/tests/test-data/api-response/application-GET-v5-expanded-app-type.json new file mode 100644 index 00000000..67fa9312 --- /dev/null +++ b/tests/test-data/api-response/application-GET-v5-expanded-app-type.json @@ -0,0 +1,35 @@ +{ + "d": [ + { + "application_type": [ + { + "name": "Starter", + "slug": "microservices-starter", + "supports_multicontainer": true, + "is_legacy": false, + "__metadata": {} + } + ], + "id": 1301645, + "user": { + "__deferred": { + "uri": "/resin/user(43699)" + }, + "__id": 43699 + }, + "depends_on__application": null, + "actor": 3423895, + "app_name": "testApp", + "slug": "gh_user/testApp", + "commit": "96eec431d57e6976d3a756df33fde7e2", + "device_type": "raspberrypi3", + "should_track_latest_release": true, + "is_accessible_by_support_until__date": null, + "is_public": false, + "is_host": false, + "__metadata": { + "uri": "/resin/application(@id)?@id=1301645" + } + } + ] +} diff --git a/tests/commands/device/device.api-response.missing-app.json b/tests/test-data/api-response/device-missing-app.json similarity index 100% rename from tests/commands/device/device.api-response.missing-app.json rename to tests/test-data/api-response/device-missing-app.json diff --git a/tests/commands/device/device-types.api-response.json b/tests/test-data/api-response/device-types-GET-v1.json similarity index 100% rename from tests/commands/device/device-types.api-response.json rename to tests/test-data/api-response/device-types-GET-v1.json diff --git a/tests/commands/device/device.api-response.json b/tests/test-data/api-response/device.json similarity index 100% rename from tests/commands/device/device.api-response.json rename to tests/test-data/api-response/device.json diff --git a/tests/commands/device/devices.api-response.json b/tests/test-data/api-response/devices.json similarity index 100% rename from tests/commands/device/devices.api-response.json rename to tests/test-data/api-response/devices.json diff --git a/tests/test-data/api-response/image-POST-v5.json b/tests/test-data/api-response/image-POST-v5.json new file mode 100644 index 00000000..17f7fb32 --- /dev/null +++ b/tests/test-data/api-response/image-POST-v5.json @@ -0,0 +1,25 @@ +{ + "created_at": "2020-01-16T17:08:56.652Z", + "id": 1859016, + "start_timestamp": "2020-01-16T17:08:56.219Z", + "end_timestamp": null, + "dockerfile": null, + "is_a_build_of__service": { + "__deferred": { + "uri": "/resin/service(233455)" + }, + "__id": 233455 + }, + "image_size": null, + "is_stored_at__image_location": "registry2.balena-cloud.com/v2/c089c421fb2336d0475166fbf3d0f9fa", + "project_type": null, + "error_message": null, + "build_log": null, + "push_timestamp": null, + "status": "running", + "content_hash": null, + "contract": null, + "__metadata": { + "uri": "/resin/image(@id)?@id=1859016" + } +} \ No newline at end of file diff --git a/tests/test-data/api-response/image-is-part-of-release-POST-v5.json b/tests/test-data/api-response/image-is-part-of-release-POST-v5.json new file mode 100644 index 00000000..d45645ab --- /dev/null +++ b/tests/test-data/api-response/image-is-part-of-release-POST-v5.json @@ -0,0 +1,19 @@ +{ + "id": 1774668, + "created_at": "2020-01-16T17:08:57.043Z", + "image": { + "__deferred": { + "uri": "/resin/image(1859016)" + }, + "__id": 1859016 + }, + "is_part_of__release": { + "__deferred": { + "uri": "/resin/release(1218643)" + }, + "__id": 1218643 + }, + "__metadata": { + "uri": "/resin/image__is_part_of__release(@id)?@id=1774668" + } +} \ No newline at end of file diff --git a/tests/test-data/api-response/image-label-POST-v5.json b/tests/test-data/api-response/image-label-POST-v5.json new file mode 100644 index 00000000..a22b53c9 --- /dev/null +++ b/tests/test-data/api-response/image-label-POST-v5.json @@ -0,0 +1,15 @@ +{ + "id": 99699617, + "created_at": "2020-01-16T17:08:57.443Z", + "release_image": { + "__deferred": { + "uri": "/resin/image__is_part_of__release(1774668)" + }, + "__id": 1774668 + }, + "label_name": "io.resin.features.firmware", + "value": "1", + "__metadata": { + "uri": "/resin/image_label(@id)?@id=99699617" + } +} \ No newline at end of file diff --git a/tests/test-data/api-response/release-GET-v5.json b/tests/test-data/api-response/release-GET-v5.json new file mode 100644 index 00000000..5ecfce53 --- /dev/null +++ b/tests/test-data/api-response/release-GET-v5.json @@ -0,0 +1,52 @@ +{ + "d": [ + { + "contains__image": [ + { + "image": [ + { + "id": 1820810, + "created_at": "2020-01-04T01:13:08.805Z", + "start_timestamp": "2020-01-04T01:13:08.583Z", + "end_timestamp": "2020-01-04T01:13:11.920Z", + "dockerfile": "# FROM busybox\n# FROM arm32v7/busybox\n# FROM arm32v7/alpine\n# FROM eu.gcr.io/buoyant-idea-226013/arm32v7/busybox\n# FROM eu.gcr.io/buoyant-idea-226013/amd64/busybox\n# FROM balenalib/raspberrypi3-debian:jessie-build\nFROM balenalib/raspberrypi3:stretch\nENV UDEV=1\n\n# FROM sander85/rpi-busybox # armv6\n# FROM balenalib/raspberrypi3-alpine\n\n# COPY start.sh /\n# COPY /src/start.sh /src/start.sh\n# COPY /src/hello.txt /\n# COPY src/hi.txt /\n\n# RUN cat /hello.txt\n# RUN cat /hi.txt\n# RUN cat /run/secrets/my-secret.txt\n# EXPOSE 80\nRUN uname -a\n\n# FROM alpine\n# RUN apk update && apk add bash\n# SHELL [\"/bin/bash\", \"-c\"]\n# CMD for ((i=1; i > 0; i++)); do echo \"(Plain Dockerfile 34-$i) $(uname -a)\"; sleep ${INTERVAL=5}; done\n\n# CMD i=1; while :; do echo \"Plain Dockerfile 36 ($i) $(uname -a)\"; sleep 10; i=$((i+1)); done\n# ENTRYPOINT [\"/usr/bin/entry.sh\"]\nCMD [\"/bin/bash\"]\n", + "is_a_build_of__service": { + "__deferred": { + "uri": "/resin/service(233455)" + }, + "__id": 233455 + }, + "image_size": 134320410, + "is_stored_at__image_location": "registry2.balena-cloud.com/v2/9c00c9413942cd15cfc9189c5dac359d", + "project_type": "Standard Dockerfile", + "error_message": null, + "build_log": "Step 1/4 : FROM balenalib/raspberrypi3:stretch\n ---> 8a75ea61d9c0\nStep 2/4 : ENV UDEV=1\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> 159206067c8a\nStep 3/4 : RUN uname -a\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> dd1b3d9c334b\nStep 4/4 : CMD [\"/bin/bash\"]\n\u001b[42m\u001b[30mUsing cache\u001b[39m\u001b[49m\n ---> 5211b6f4bb72\nSuccessfully built 5211b6f4bb72\n", + "push_timestamp": "2020-01-04T01:13:14.415Z", + "status": "success", + "content_hash": "sha256:6b5471aae43ae81e8f69e10d1a516cb412569a6d5020a57eae311f8fa16d688a", + "contract": null, + "__metadata": { + "uri": "/resin/image(@id)?@id=1820810" + } + } + ], + "id": 1738663, + "created_at": "2020-01-04T01:13:14.646Z", + "is_part_of__release": { + "__deferred": { + "uri": "/resin/release(1203844)" + }, + "__id": 1203844 + }, + "__metadata": { + "uri": "/resin/image__is_part_of__release(@id)?@id=1738663" + } + } + ], + "id": 1203844, + "__metadata": { + "uri": "/resin/release(@id)?@id=1203844" + } + } + ] +} diff --git a/tests/test-data/api-response/release-POST-v5.json b/tests/test-data/api-response/release-POST-v5.json new file mode 100644 index 00000000..ab5ca345 --- /dev/null +++ b/tests/test-data/api-response/release-POST-v5.json @@ -0,0 +1,54 @@ +{ + "id": 1218643, + "created_at": "2020-01-16T17:08:53.016Z", + "belongs_to__application": { + "__deferred": { + "uri": "/resin/application(1301645)" + }, + "__id": 1301645 + }, + "is_created_by__user": { + "__deferred": { + "uri": "/resin/user(43699)" + }, + "__id": 43699 + }, + "commit": "09f7c3e1fdec609be818002299edfc2a", + "composition": { + "version": "2.1", + "networks": {}, + "volumes": { + "resin-data": {} + }, + "services": { + "main": { + "build": { + "context": "." + }, + "privileged": true, + "tty": true, + "restart": "always", + "network_mode": "host", + "volumes": [ + "resin-data:/data" + ], + "labels": { + "io.resin.features.kernel-modules": "1", + "io.resin.features.firmware": "1", + "io.resin.features.dbus": "1", + "io.resin.features.supervisor-api": "1", + "io.resin.features.resin-api": "1" + } + } + } + }, + "status": "running", + "source": "local", + "build_log": null, + "start_timestamp": "2020-01-16T17:08:52.710Z", + "end_timestamp": null, + "update_timestamp": "2020-01-16T17:08:53.017Z", + "__metadata": { + "uri": "/resin/release(@id)?@id=1218643" + } +} \ No newline at end of file diff --git a/tests/test-data/builder-response/build-POST-v3.json b/tests/test-data/builder-response/build-POST-v3.json new file mode 100644 index 00000000..93c4d09f --- /dev/null +++ b/tests/test-data/builder-response/build-POST-v3.json @@ -0,0 +1,99 @@ +[ + {"type":"metadata","resource":"buildLogId","value":1220245} + , + {"message":"\u001b[36m[Info]\u001b[39m Starting build for testApp, user gh_user"} + , + {"message":"\u001b[36m[Info]\u001b[39m Dashboard link: https://dashboard.balena-cloud.com/apps/1301645/devices"} + , + {"message":"\u001b[36m[Info]\u001b[39m Building on arm01"} + , + {"message":"\u001b[36m[Info]\u001b[39m Pulling previous images for caching purposes..."} + , + {"message":"[=> ] 2%","replace":true} + , + {"message":"[===> ] 6%","replace":true} + , + {"message":"[======> ] 13%","replace":true} + , + {"message":"[=================================================> ] 98%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"type":"metadata","resource":"cursor","value":"erase"} + , + {"message":"\u001b[32m[Success]\u001b[39m Successfully pulled cache images"} + , + {"message":"\u001b[34m[main]\u001b[39m Step 1/4 : FROM busybox"} + , + {"message":"\u001b[34m[main]\u001b[39m ---> 76aea0766768"} + , + {"message":"\u001b[34m[main]\u001b[39m Step 2/4 : COPY ./src/start.sh /start.sh"} + , + {"message":"\u001b[34m[main]\u001b[39m ---> b563ad6a0801"} + , + {"message":"\u001b[34m[main]\u001b[39m Step 3/4 : RUN chmod a+x /start.sh"} + , + {"message":"\u001b[34m[main]\u001b[39m ---> Running in 10d4ddc40bfc"} + , + {"message":"\u001b[34m[main]\u001b[39m Removing intermediate container 10d4ddc40bfc"} + , + {"message":"\u001b[34m[main]\u001b[39m ---> 82e98871a32c"} + , + {"message":"\u001b[34m[main]\u001b[39m Step 4/4 : CMD [\"/start.sh\"]"} + , + {"message":"\u001b[34m[main]\u001b[39m ---> Running in 0682894e13eb"} + , + {"message":"\u001b[34m[main]\u001b[39m Removing intermediate container 0682894e13eb"} + , + {"message":"\u001b[34m[main]\u001b[39m ---> 889ccb6afc7c"} + , + {"message":"\u001b[34m[main]\u001b[39m Successfully built 889ccb6afc7c"} + , + {"message":"\u001b[36m[Info]\u001b[39m Uploading images"} + , + {"message":"[================> ] 33%","replace":true} + , + {"message":"[=========================> ] 50%","replace":true} + , + {"message":"[=================================> ] 67%","replace":true} + , + {"message":"[=================================> ] 67%","replace":true} + , + {"message":"[==========================================> ] 84%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"message":"[==================================================>] 100%","replace":true} + , + {"type":"metadata","resource":"cursor","value":"erase"} + , + {"message":"\u001b[32m[Success]\u001b[39m Successfully uploaded images"} + , + {"message":"\u001b[36m[Info]\u001b[39m Built on arm01"} + , + {"message":"\u001b[32m[Success]\u001b[39m Release successfully created!"} + , + {"message":"\u001b[36m[Info]\u001b[39m Release: \u001b[34m05a24b5b034c9f95f25d4d74f0593bea\u001b[39m (id: \u001b[32m1220245\u001b[39m)"} + , + {"message":"\u001b[36m[Info]\u001b[39m \u001b[90m┌─────────\u001b[39m\u001b[90m┬────────────\u001b[39m\u001b[90m┬────────────┐\u001b[39m"} + , + {"message":"\u001b[36m[Info]\u001b[39m \u001b[90m│\u001b[39m \u001b[1mService\u001b[22m \u001b[90m│\u001b[39m \u001b[1mImage Size\u001b[22m \u001b[90m│\u001b[39m \u001b[1mBuild Time\u001b[22m \u001b[90m│\u001b[39m"} + , + {"message":"\u001b[36m[Info]\u001b[39m \u001b[90m├─────────\u001b[39m\u001b[90m┼────────────\u001b[39m\u001b[90m┼────────────┤\u001b[39m"} + , + {"message":"\u001b[36m[Info]\u001b[39m \u001b[90m│\u001b[39m main \u001b[90m│\u001b[39m 1.32 MB \u001b[90m│\u001b[39m 11 seconds \u001b[90m│\u001b[39m"} + , + {"message":"\u001b[36m[Info]\u001b[39m \u001b[90m└─────────\u001b[39m\u001b[90m┴────────────\u001b[39m\u001b[90m┴────────────┘\u001b[39m"} + , + {"message":"\u001b[36m[Info]\u001b[39m Build finished in 26 seconds"} + , + {"message":"\u001b[1m\u001b[34m\t\t\t \\\n\t\t\t \\\n\t\t\t \\\\\n\t\t\t \\\\\n\t\t\t >\\/7\n\t\t\t _.-(6' \\\n\t\t\t (=___._/` \\\n\t\t\t ) \\ |\n\t\t\t / / |\n\t\t\t / > /\n\t\t\t j < _\\\n\t\t\t _.-' : ``.\n\t\t\t \\ r=._\\ `.\n\t\t\t<`\\\\_ \\ .`-.\n\t\t\t \\ r-7 `-. ._ ' . `\\\n\t\t\t \\`, `-.`7 7) )\n\t\t\t \\/ \\| \\' / `-._\n\t\t\t || .'\n\t\t\t \\\\ (\n\t\t\t >\\ >\n\t\t\t ,.-' >.'\n\t\t\t <.'_.''\n\t\t\t <'\u001b[39m\u001b[22m","isSuccess":true} +] diff --git a/tests/test-data/docker-response/images-push-POST.json b/tests/test-data/docker-response/images-push-POST.json new file mode 100644 index 00000000..20fb2272 --- /dev/null +++ b/tests/test-data/docker-response/images-push-POST.json @@ -0,0 +1,19 @@ +{"status":"The push refers to repository [registry2.balena-cloud.com/v2/c089c421fb2336d0475166fbf3d0f9fa]"} +{"status":"Preparing","progressDetail":{},"id":"a5b1f6c006f8"} +{"status":"Preparing","progressDetail":{},"id":"2b74be40c29e"} +{"status":"Preparing","progressDetail":{},"id":"d1156b98822d"} +{"status":"Pushing","progressDetail":{"current":512,"total":89},"progress":"[==================================================\u003e] 512B","id":"a5b1f6c006f8"} +{"status":"Pushing","progressDetail":{"current":2048,"total":89},"progress":"[==================================================\u003e] 2.048kB","id":"a5b1f6c006f8"} +{"status":"Pushing","progressDetail":{"current":512,"total":89},"progress":"[==================================================\u003e] 512B","id":"2b74be40c29e"} +{"status":"Pushing","progressDetail":{"current":33792,"total":1199418},"progress":"[=\u003e ] 33.79kB/1.199MB","id":"d1156b98822d"} +{"status":"Pushing","progressDetail":{"current":2048,"total":89},"progress":"[==================================================\u003e] 2.048kB","id":"2b74be40c29e"} +{"status":"Pushing","progressDetail":{"current":99328,"total":1199418},"progress":"[====\u003e ] 99.33kB/1.199MB","id":"d1156b98822d"} +{"status":"Pushing","progressDetail":{"current":787456,"total":1199418},"progress":"[================================\u003e ] 787.5kB/1.199MB","id":"d1156b98822d"} +{"status":"Pushing","progressDetail":{"current":852992,"total":1199418},"progress":"[===================================\u003e ] 853kB/1.199MB","id":"d1156b98822d"} +{"status":"Pushing","progressDetail":{"current":951296,"total":1199418},"progress":"[=======================================\u003e ] 951.3kB/1.199MB","id":"d1156b98822d"} +{"status":"Pushing","progressDetail":{"current":1415680,"total":1199418},"progress":"[==================================================\u003e] 1.416MB","id":"d1156b98822d"} +{"status":"Pushed","progressDetail":{},"id":"a5b1f6c006f8"} +{"status":"Pushed","progressDetail":{},"id":"2b74be40c29e"} +{"status":"Pushed","progressDetail":{},"id":"d1156b98822d"} +{"status":"latest: digest: sha256:444a5e0c57eed51f5e752b908cb95188c25a0476fc6e5f43e5113edfc4d07199 size: 941"} +{"progressDetail":{},"aux":{"Tag":"latest","Digest":"sha256:444a5e0c57eed51f5e752b908cb95188c25a0476fc6e5f43e5113edfc4d07199","Size":941}} diff --git a/tests/test-data/projects/no-docker-compose/basic/Dockerfile b/tests/test-data/projects/no-docker-compose/basic/Dockerfile new file mode 100644 index 00000000..6c6f9c81 --- /dev/null +++ b/tests/test-data/projects/no-docker-compose/basic/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox +COPY ./src/start.sh /start.sh +RUN chmod a+x /start.sh +CMD ["/start.sh"] diff --git a/tests/test-data/projects/no-docker-compose/basic/src/start.sh b/tests/test-data/projects/no-docker-compose/basic/src/start.sh new file mode 100755 index 00000000..39a7dff8 --- /dev/null +++ b/tests/test-data/projects/no-docker-compose/basic/src/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +i=1; while :; do echo "basic test ($i) $(uname -a)"; sleep 5; i=$((i+1)); done From b978230f9e15c8a42feffbf5d07469b5e1ab2b3a Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 20 Jan 2020 21:21:05 +0000 Subject: [PATCH 2/3] Update resin-lint and prettier, and re-prettify Change-type: patch Signed-off-by: Paulo Castro --- automation/build-bin.ts | 4 +- automation/capitanodoc/utils.ts | 4 +- automation/deploy-bin.ts | 12 +--- lib/actions-oclif/devices/supported.ts | 6 +- lib/actions-oclif/envs.ts | 4 +- lib/actions-oclif/os/configure.ts | 4 +- lib/actions/push.ts | 4 +- lib/actions/ssh.ts | 8 +-- lib/actions/tunnel.ts | 4 +- lib/app-common.ts | 4 +- lib/preparser.ts | 4 +- lib/utils/compose_ts.ts | 8 +-- lib/utils/device/deploy.ts | 8 +-- lib/utils/helpers.ts | 8 +-- lib/utils/ignore.ts | 5 +- lib/utils/patterns.ts | 20 +++--- lib/utils/remote-build.ts | 4 +- npm-shrinkwrap.json | 98 ++++++++++++-------------- package.json | 4 +- tests/nock-mock.ts | 4 +- 20 files changed, 87 insertions(+), 130 deletions(-) diff --git a/automation/build-bin.ts b/automation/build-bin.ts index 5c2716bc..73527cb4 100644 --- a/automation/build-bin.ts +++ b/automation/build-bin.ts @@ -183,9 +183,7 @@ async function testPkg() { } if (semver.major(process.version) !== pkgNodeMajorVersion) { throw new Error( - `Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${ - process.version - }"`, + `Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`, ); } console.log('Success! (standalone package test successful)'); diff --git a/automation/capitanodoc/utils.ts b/automation/capitanodoc/utils.ts index fe856a10..5d6c9b81 100644 --- a/automation/capitanodoc/utils.ts +++ b/automation/capitanodoc/utils.ts @@ -127,9 +127,7 @@ export class MarkdownFileParser { } else { reject( new Error( - `Markdown section not found: title="${title}" file="${ - this.mdFilePath - }"`, + `Markdown section not found: title="${title}" file="${this.mdFilePath}"`, ), ); } diff --git a/automation/deploy-bin.ts b/automation/deploy-bin.ts index 20e18407..77aec0af 100644 --- a/automation/deploy-bin.ts +++ b/automation/deploy-bin.ts @@ -74,9 +74,7 @@ function getOctokit(): any { throttle: { onRateLimit: (retryAfter: number, options: any) => { console.warn( - `Request quota exhausted for request ${options.method} ${ - options.url - }`, + `Request quota exhausted for request ${options.method} ${options.url}`, ); // retries 3 times if (options.request.retryCount < 3) { @@ -174,9 +172,7 @@ async function updateGitHubReleaseDescriptions( ); continue; } - const skipMsg = `${prefix} skipping release "${cliRelease.tag_name}" (${ - cliRelease.id - })`; + const skipMsg = `${prefix} skipping release "${cliRelease.tag_name}" (${cliRelease.id})`; if (cliRelease.draft === true) { console.info(`${skipMsg}: draft release`); continue; @@ -201,9 +197,7 @@ async function updateGitHubReleaseDescriptions( } } console.info( - `${prefix} updating release "${cliRelease.tag_name}" (${ - cliRelease.id - }) old body="${oldBodyPreview}"`, + `${prefix} updating release "${cliRelease.tag_name}" (${cliRelease.id}) old body="${oldBodyPreview}"`, ); try { await octokit.repos.updateRelease(updatedRelease); diff --git a/lib/actions-oclif/devices/supported.ts b/lib/actions-oclif/devices/supported.ts index 86a434b3..d9a5de93 100644 --- a/lib/actions-oclif/devices/supported.ts +++ b/lib/actions-oclif/devices/supported.ts @@ -80,9 +80,9 @@ export default class DevicesSupportedCmd extends Command { public async run() { const { flags: options } = this.parse(DevicesSupportedCmd); const sdk = SDK.fromSharedOptions(); - let deviceTypes: Array< - Partial - > = await sdk.models.config.getDeviceTypes(); + let deviceTypes: Array> = await sdk.models.config.getDeviceTypes(); if (!options.discontinued) { deviceTypes = deviceTypes.filter(dt => dt.state !== 'DISCONTINUED'); } diff --git a/lib/actions-oclif/envs.ts b/lib/actions-oclif/envs.ts index 4653202c..ec70b6f9 100644 --- a/lib/actions-oclif/envs.ts +++ b/lib/actions-oclif/envs.ts @@ -299,9 +299,7 @@ async function getDeviceVars( deviceVars.push(...deviceConfigVars); } else { if (options.service || options.all) { - const pineOpts: SDK.PineOptionsFor< - SDK.DeviceServiceEnvironmentVariable - > = { + const pineOpts: SDK.PineOptionsFor = { $expand: { service_install: { $expand: 'installs__service', diff --git a/lib/actions-oclif/os/configure.ts b/lib/actions-oclif/os/configure.ts index 9092f73e..178ddef2 100644 --- a/lib/actions-oclif/os/configure.ts +++ b/lib/actions-oclif/os/configure.ts @@ -306,9 +306,7 @@ async function checkDeviceTypeCompatibility( const helpers = await import('../../utils/helpers'); if (!helpers.areDeviceTypesCompatible(appDeviceType, optionDeviceType)) { throw new ExpectedError( - `Device type ${ - options['device-type'] - } is incompatible with application ${options.application}`, + `Device type ${options['device-type']} is incompatible with application ${options.application}`, ); } } diff --git a/lib/actions/push.ts b/lib/actions/push.ts index 55e5ef34..63e57845 100644 --- a/lib/actions/push.ts +++ b/lib/actions/push.ts @@ -88,9 +88,7 @@ async function getAppOwner(sdk: BalenaSDK, appName: string) { }); const selected = await selectFromList( - `${ - entries.length - } applications found with that name, please select the application you would like to push to`, + `${entries.length} applications found with that name, please select the application you would like to push to`, entries, ); diff --git a/lib/actions/ssh.ts b/lib/actions/ssh.ts index 07de9e84..a1688e4b 100644 --- a/lib/actions/ssh.ts +++ b/lib/actions/ssh.ts @@ -58,17 +58,13 @@ async function getContainerId( }); if (request.status !== 200) { throw new Error( - `There was an error connecting to device ${uuid}, HTTP response code: ${ - request.status - }.`, + `There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`, ); } const body = request.body; if (body.status !== 'success') { throw new Error( - `There was an error communicating with device ${uuid}.\n\tError: ${ - body.message - }`, + `There was an error communicating with device ${uuid}.\n\tError: ${body.message}`, ); } containerId = body.services[serviceName]; diff --git a/lib/actions/tunnel.ts b/lib/actions/tunnel.ts index 40bbd8c9..c936d943 100644 --- a/lib/actions/tunnel.ts +++ b/lib/actions/tunnel.ts @@ -206,9 +206,7 @@ export const tunnel: CommandDefinition = { ) .then(() => { logger.logInfo( - ` - tunnelling ${localAddress}:${localPort} to ${ - device.uuid - }:${remotePort}`, + ` - tunnelling ${localAddress}:${localPort} to ${device.uuid}:${remotePort}`, ); return true; diff --git a/lib/app-common.ts b/lib/app-common.ts index 72342da0..67fd8141 100644 --- a/lib/app-common.ts +++ b/lib/app-common.ts @@ -45,9 +45,7 @@ function checkNodeVersion() { const { stripIndent } = require('common-tags'); console.warn(stripIndent` ------------------------------------------------------------------------------ - Warning: Node version "${ - process.version - }" does not match required versions "${validNodeVersions}". + Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}". This may cause unexpected behavior. To upgrade Node, visit: https://nodejs.org/en/download/ ------------------------------------------------------------------------------ diff --git a/lib/preparser.ts b/lib/preparser.ts index d07d6eba..5e890065 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -30,9 +30,7 @@ export interface AppOptions { export async function routeCliFramework(argv: string[], options: AppOptions) { if (process.env.DEBUG) { console.log( - `[debug] original argv0="${process.argv0}" argv=[${argv}] length=${ - argv.length - }`, + `[debug] original argv0="${process.argv0}" argv=[${argv}] length=${argv.length}`, ); } const cmdSlice = argv.slice(2); diff --git a/lib/utils/compose_ts.ts b/lib/utils/compose_ts.ts index 548269ae..91fd7a7a 100644 --- a/lib/utils/compose_ts.ts +++ b/lib/utils/compose_ts.ts @@ -157,9 +157,7 @@ async function parseRegistrySecrets( return registrySecrets; } catch (error) { return exitWithExpectedError( - `Error validating registry secrets file "${secretsFilename}":\n${ - error.message - }`, + `Error validating registry secrets file "${secretsFilename}":\n${error.message}`, ); } } @@ -252,9 +250,7 @@ async function performResolution( buildTask.buildStream = clonedStream; if (!buildTask.external && !buildTask.resolved) { throw new Error( - `Project type for service "${ - buildTask.serviceName - }" could not be determined. Missing a Dockerfile?`, + `Project type for service "${buildTask.serviceName}" could not be determined. Missing a Dockerfile?`, ); } return buildTask; diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index 788a14fd..dd14bef4 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -95,9 +95,7 @@ async function environmentFromInput( // exists if (!(match[1] in ret)) { logger.logDebug( - `Warning: Cannot find a service with name ${ - match[1] - }. Treating the string as part of the environment variable name.`, + `Warning: Cannot find a service with name ${match[1]}. Treating the string as part of the environment variable name.`, ); match[2] = `${match[1]}:${match[2]}`; } else { @@ -135,9 +133,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { await api.ping(); } catch (e) { exitWithExpectedError( - `Could not communicate with local mode device at address ${ - opts.deviceHost - }`, + `Could not communicate with local mode device at address ${opts.deviceHost}`, ); } diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index f207acd8..e9797fef 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -45,13 +45,9 @@ export function stateToString(state: OperationState) { switch (state.operation.command) { case 'copy': - return `${result} ${state.operation.from.path} -> ${ - state.operation.to.path - }`; + return `${result} ${state.operation.from.path} -> ${state.operation.to.path}`; case 'replace': - return `${result} ${state.operation.file.path}, ${ - state.operation.copy - } -> ${state.operation.replace}`; + return `${result} ${state.operation.file.path}, ${state.operation.copy} -> ${state.operation.replace}`; case 'run-script': return `${result} ${state.operation.script}`; default: diff --git a/lib/utils/ignore.ts b/lib/utils/ignore.ts index 83dcac10..6e5b8822 100644 --- a/lib/utils/ignore.ts +++ b/lib/utils/ignore.ts @@ -116,7 +116,10 @@ export class FileIgnorer { } // Don't ignore Dockerfile (with or without extension) or docker-compose.yml - if (/^Dockerfile$|^Dockerfile\.\S+/.test(path.basename(relFile)) || path.basename(relFile) === 'docker-compose.yml') { + if ( + /^Dockerfile$|^Dockerfile\.\S+/.test(path.basename(relFile)) || + path.basename(relFile) === 'docker-compose.yml' + ) { return true; } diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index 59d7125a..640aaa5e 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -366,10 +366,12 @@ export async function getOnlineTargetUuid( logger.logDebug( `Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`, ); - return (await sdk.models.device.get(applicationOrDevice, { - $select: ['uuid'], - $filter: { is_online: true }, - })).uuid; + return ( + await sdk.models.device.get(applicationOrDevice, { + $select: ['uuid'], + $filter: { is_online: true }, + }) + ).uuid; } // otherwise, it may be a device OR an application... @@ -409,10 +411,12 @@ export async function getOnlineTargetUuid( logger.logDebug( `Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`, ); - return (await sdk.models.device.get(applicationOrDevice, { - $select: ['uuid'], - $filter: { is_online: true }, - })).uuid; + return ( + await sdk.models.device.get(applicationOrDevice, { + $select: ['uuid'], + $filter: { is_online: true }, + }) + ).uuid; } export function selectFromList( diff --git a/lib/utils/remote-build.ts b/lib/utils/remote-build.ts index a22531e4..5360fab8 100644 --- a/lib/utils/remote-build.ts +++ b/lib/utils/remote-build.ts @@ -332,9 +332,7 @@ function createRemoteBuildRequest( if (response.statusCode >= 100 && response.statusCode < 400) { if (DEBUG_MODE) { console.error( - `[debug] received HTTP ${response.statusCode} ${ - response.statusMessage - }`, + `[debug] received HTTP ${response.statusCode} ${response.statusMessage}`, ); } } else { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7565742a..68a8c2ba 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -707,9 +707,9 @@ "dev": true }, "@types/prettier": { - "version": "1.16.4", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-1.16.4.tgz", - "integrity": "sha512-MG7ExKBo7AQ5UrL1awyYLNinNM/kyXgE4iP4Ul9fB+T7n768Z5Xem8IZeP6Bna0xze8gkDly49Rgge2HOEw4xA==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-1.19.0.tgz", + "integrity": "sha512-gDE8JJEygpay7IjA/u3JiIURvwZW08f0cZSZLAzFoX/ZmeqvS0Sqv+97aKuHpNsalAMMhwPe+iAS6fQbfmbt7A==", "dev": true }, "@types/prettyjson": { @@ -3033,6 +3033,12 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "coffee-script": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", + "dev": true + }, "coffeelint": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/coffeelint/-/coffeelint-1.16.2.tgz", @@ -3760,17 +3766,6 @@ "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", "which": "^1.2.9" - }, - "dependencies": { - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } } }, "execa": { @@ -3905,6 +3900,15 @@ "read-pkg": "^2.0.0" } }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", @@ -3949,9 +3953,9 @@ "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, "deprecate": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/deprecate/-/deprecate-1.1.0.tgz", - "integrity": "sha512-b5dDNQYdy2vW9WXUD8+RQlfoxvqztLLhDE+T7Gd37I5E8My7nJkKu6FmhdDeRWJ8B+yjZKuwjCta8pgi8kgSqA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deprecate/-/deprecate-1.1.1.tgz", + "integrity": "sha512-ZGDXefq1xknT292LnorMY5s8UVU08/WKdzDZCUT6t9JzsiMSP4uzUhgpqugffNVcT5WC6wMBiSQ+LFjlv3v7iQ==", "dev": true }, "deprecation": { @@ -13873,9 +13877,9 @@ "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" }, "prettier": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.17.0.tgz", - "integrity": "sha512-sXe5lSt2WQlCbydGETgfm1YBShgOX4HxQkFPvbxkcwgDvGDeqVau8h+12+lmSVlP3rHPz0oavfddSZg/q+Szjw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "dev": true }, "pretty-bytes": { @@ -15478,9 +15482,9 @@ } }, "resin-lint": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/resin-lint/-/resin-lint-3.0.4.tgz", - "integrity": "sha512-TVxY7SaJqQRZcLubJn5yO49db/M4eRXRr7FbA4xwqSYxQSqujNql8ThMoNMoRrx+1F7NrfSdhIsLEaMqCea4VA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/resin-lint/-/resin-lint-3.1.1.tgz", + "integrity": "sha512-BgIsrj9fvWcELoqfiu0dGflqkysByn7m/XVgbv19YdnnVToEtyQkFzfF9oY+h6nnr45pRYkorE6NAFYaVaYhLQ==", "dev": true, "requires": { "@types/bluebird": "^3.5.26", @@ -15488,7 +15492,7 @@ "@types/glob": "^5.0.35", "@types/node": "^8.10.45", "@types/optimist": "0.0.29", - "@types/prettier": "^1.16.1", + "@types/prettier": "^1.18.3", "bluebird": "^3.5.4", "coffee-script": "^1.10.0", "coffeelint": "^1.15.0", @@ -15497,7 +15501,7 @@ "glob": "^7.0.3", "merge": "^1.2.0", "optimist": "^0.6.1", - "prettier": "^1.16.4", + "prettier": "^1.19.1", "tslint": "^5.15.0", "tslint-config-prettier": "^1.18.0", "tslint-no-unused-expression-chai": "^0.1.4", @@ -15505,9 +15509,9 @@ }, "dependencies": { "@types/bluebird": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.27.tgz", - "integrity": "sha512-6BmYWSBea18+tSjjSC3QIyV93ZKAeNWGM7R6aYt1ryTZXrlHF+QLV0G2yV0viEGVyRkyQsWfMoJ0k/YghBX5sQ==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.29.tgz", + "integrity": "sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==", "dev": true }, "@types/glob": { @@ -15522,15 +15526,9 @@ } }, "@types/node": { - "version": "8.10.49", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.49.tgz", - "integrity": "sha512-YX30JVx0PvSmJ3Eqr74fYLGeBxD+C7vIL20ek+GGGLJeUbVYRUW3EzyAXpIRA0K8c8o0UWqR/GwEFYiFoz1T8w==", - "dev": true - }, - "coffee-script": { - "version": "1.12.7", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", - "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", + "version": "8.10.59", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.59.tgz", + "integrity": "sha512-8RkBivJrDCyPpBXhVZcjh7cQxVBSmRk9QM7hOketZzp6Tg79c0N8kkpAIito9bnJ3HCVCHVYz+KHTEbfQNfeVQ==", "dev": true } } @@ -17141,16 +17139,16 @@ "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" }, "tslint": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.18.0.tgz", - "integrity": "sha512-Q3kXkuDEijQ37nXZZLKErssQVnwCV/+23gFEMROi8IlbaBG6tXqLPQJ5Wjcyt/yHPKBC+hD5SzuGaMora+ZS6w==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.20.1.tgz", + "integrity": "sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", "chalk": "^2.3.0", "commander": "^2.12.1", - "diff": "^3.2.0", + "diff": "^4.0.1", "glob": "^7.1.1", "js-yaml": "^3.13.1", "minimatch": "^3.0.4", @@ -17162,15 +17160,9 @@ }, "dependencies": { "commander": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", - "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", - "dev": true - }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true } } @@ -17191,9 +17183,9 @@ }, "dependencies": { "tsutils": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.14.0.tgz", - "integrity": "sha512-SmzGbB0l+8I0QwsPgjooFRaRvHLBLNYM8SeQ0k6rtNDru5sCGeLJcZdwilNndN+GysuFjF5EIYgN8GfFG6UeUw==", + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", + "integrity": "sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==", "dev": true, "requires": { "tslib": "^1.8.1" diff --git a/package.json b/package.json index bf061ca2..1d83e397 100644 --- a/package.json +++ b/package.json @@ -140,9 +140,9 @@ "nock": "^11.0.7", "parse-link-header": "~1.0.1", "pkg": "^4.4.0", - "prettier": "1.17.0", + "prettier": "^1.19.1", "publish-release": "^1.6.0", - "resin-lint": "^3.0.1", + "resin-lint": "^3.1.1", "rewire": "^3.0.2", "sinon": "^7.4.1", "ts-node": "^8.1.0", diff --git a/tests/nock-mock.ts b/tests/nock-mock.ts index 7659e610..3543ca0d 100644 --- a/tests/nock-mock.ts +++ b/tests/nock-mock.ts @@ -45,9 +45,7 @@ export class NockMock { nock.emitter.on('no match', this.handleUnexpectedRequest); } else if (process.env.DEBUG) { console.error( - `[debug] NockMock.constructor() instance count is ${ - NockMock.instanceCount - }`, + `[debug] NockMock.constructor() instance count is ${NockMock.instanceCount}`, ); } NockMock.instanceCount += 1; From 9db6961a7eb3905f37abe55e33989029128d2612 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 20 Jan 2020 14:01:58 +0000 Subject: [PATCH 3/3] Add `catch-uncommitted` to balena CI build Change-type: patch Signed-off-by: Paulo Castro --- automation/build-bin.ts | 122 +++++++--------------------- automation/run.ts | 8 +- automation/utils.ts | 174 ++++++++++++++++++++++++++++++++++++++++ npm-shrinkwrap.json | 6 +- package.json | 5 +- 5 files changed, 213 insertions(+), 102 deletions(-) create mode 100644 automation/utils.ts diff --git a/automation/build-bin.ts b/automation/build-bin.ts index 73527cb4..a412b236 100644 --- a/automation/build-bin.ts +++ b/automation/build-bin.ts @@ -18,7 +18,7 @@ import { run as oclifRun } from '@oclif/dev-cli'; import * as archiver from 'archiver'; import * as Bluebird from 'bluebird'; -import { execFile, spawn } from 'child_process'; +import { execFile } from 'child_process'; import { stripIndent } from 'common-tags'; import * as filehound from 'filehound'; import * as fs from 'fs-extra'; @@ -27,15 +27,17 @@ import * as path from 'path'; import { exec as execPkg } from 'pkg'; import * as rimraf from 'rimraf'; import * as semver from 'semver'; -import * as shellEscape from 'shell-escape'; import * as util from 'util'; -export const ROOT = path.join(__dirname, '..'); -// Note: the following 'tslint disable' line was only required to -// satisfy ts-node under Appveyor's MSYS2 on Windows -- oddly specific. -// Maybe something to do with '/' vs '\' in paths in some tslint file. -// tslint:disable-next-line:no-var-requires -export const packageJSON = require(path.join(ROOT, 'package.json')); +import { + getSubprocessStdout, + loadPackageJson, + MSYS2_BASH, + ROOT, + whichSpawn, +} from './utils'; + +export const packageJSON = loadPackageJson(); export const version = 'v' + packageJSON.version; const arch = process.arch; @@ -69,34 +71,6 @@ export const finalReleaseAssets: { [platform: string]: string[] } = { linux: [standaloneZips['linux']], }; -const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe'; - -/** - * Run the MSYS2 bash.exe shell in a child process (child_process.spawn()). - * The given argv arguments are escaped using the 'shell-escape' package, - * so that backslashes in Windows paths, and other bash-special characters, - * are preserved. If argv is not provided, defaults to process.argv, to the - * effect that this current (parent) process is re-executed under MSYS2 bash. - * This is useful to change the default shell from cmd.exe to MSYS2 bash on - * Windows. - * @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe. - */ -export async function runUnderMsys(argv?: string[]) { - const newArgv = argv || process.argv; - await new Promise((resolve, reject) => { - const args = ['-lc', shellEscape(newArgv)]; - const child = spawn(MSYS2_BASH, args, { stdio: 'inherit' }); - child.on('close', code => { - if (code) { - console.log(`runUnderMsys: child process exited with code ${code}`); - reject(code); - } else { - resolve(); - } - }); - }); -} - /** * Use the 'pkg' module to create a single large executable file with * the contents of 'node_modules' and the CLI's javascript code. @@ -313,62 +287,24 @@ export async function buildOclifInstaller() { } /** - * Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given - * as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows. + * Wrapper around the npm `catch-uncommitted` package in order to run it + * conditionally, only when: + * - A CI env var is set (CI=true), and + * - The OS is not Windows. (`catch-uncommitted` fails on Windows) */ -export function fixPathForMsys(p: string): string { - return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1'); -} - -/** - * Run the executable at execPath as a child process, and resolve a promise - * to the executable's stdout output as a string. Reject the promise if - * anything is printed to stderr, or if the child process exits with a - * non-zero exit code. - * @param execPath Executable path - * @param args Command-line argument for the executable - */ -async function getSubprocessStdout( - execPath: string, - args: string[], -): Promise { - const child = spawn(execPath, args); - return new Promise((resolve, reject) => { - let stdout = ''; - child.stdout.on('error', reject); - child.stderr.on('error', reject); - child.stdout.on('data', (data: Buffer) => { - try { - stdout = data.toString(); - } catch (err) { - reject(err); - } - }); - child.stderr.on('data', (data: Buffer) => { - try { - const stderr = data.toString(); - - // ignore any debug lines, but ensure that we parse - // every line provided to the stderr stream - const lines = _.filter( - stderr.trim().split(/\r?\n/), - line => !line.startsWith('[debug]'), - ); - if (lines.length > 0) { - reject( - new Error(`"${execPath}": non-empty stderr "${lines.join('\n')}"`), - ); - } - } catch (err) { - reject(err); - } - }); - child.on('exit', (code: number) => { - if (code) { - reject(new Error(`"${execPath}": non-zero exit code "${code}"`)); - } else { - resolve(stdout); - } - }); - }); +export async function catchUncommitted(): Promise { + if (process.env.DEBUG) { + console.error(`[debug] CI=${process.env.CI} platform=${process.platform}`); + } + if ( + process.env.CI && + ['true', 'yes', '1'].includes(process.env.CI.toLowerCase()) && + process.platform !== 'win32' + ) { + await whichSpawn('npx', [ + 'catch-uncommitted', + '--catch-no-git', + '--skip-node-versionbot-changes', + ]); + } } diff --git a/automation/run.ts b/automation/run.ts index c31e557c..45921a2b 100644 --- a/automation/run.ts +++ b/automation/run.ts @@ -20,14 +20,13 @@ import * as _ from 'lodash'; import { buildOclifInstaller, buildStandaloneZip, - fixPathForMsys, - ROOT, - runUnderMsys, + catchUncommitted, } from './build-bin'; import { release, updateDescriptionOfReleasesAffectedByIssue1359, } from './deploy-bin'; +import { fixPathForMsys, ROOT, runUnderMsys } from './utils'; function exitWithError(error: Error | string): never { console.error(`Error: ${error}`); @@ -54,9 +53,10 @@ export async function run(args?: string[]) { if (_.isEmpty(args)) { return exitWithError('missing command-line arguments'); } - const commands: { [cmd: string]: () => void } = { + const commands: { [cmd: string]: () => void | Promise } = { 'build:installer': buildOclifInstaller, 'build:standalone': buildStandaloneZip, + 'catch-uncommitted': catchUncommitted, fix1359: updateDescriptionOfReleasesAffectedByIssue1359, release, }; diff --git a/automation/utils.ts b/automation/utils.ts new file mode 100644 index 00000000..cfdd0302 --- /dev/null +++ b/automation/utils.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2019-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { spawn } from 'child_process'; +import * as _ from 'lodash'; +import * as path from 'path'; +import * as shellEscape from 'shell-escape'; + +export const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe'; +export const ROOT = path.join(__dirname, '..'); + +export function loadPackageJson() { + return require(path.join(ROOT, 'package.json')); +} + +/** + * Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given + * as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows. + */ +export function fixPathForMsys(p: string): string { + return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1'); +} + +/** + * Run the MSYS2 bash.exe shell in a child process (child_process.spawn()). + * The given argv arguments are escaped using the 'shell-escape' package, + * so that backslashes in Windows paths, and other bash-special characters, + * are preserved. If argv is not provided, defaults to process.argv, to the + * effect that this current (parent) process is re-executed under MSYS2 bash. + * This is useful to change the default shell from cmd.exe to MSYS2 bash on + * Windows. + * @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe. + */ +export async function runUnderMsys(argv?: string[]) { + const newArgv = argv || process.argv; + await new Promise((resolve, reject) => { + const args = ['-lc', shellEscape(newArgv)]; + const child = spawn(MSYS2_BASH, args, { stdio: 'inherit' }); + child.on('close', code => { + if (code) { + console.log(`runUnderMsys: child process exited with code ${code}`); + reject(code); + } else { + resolve(); + } + }); + }); +} + +/** + * Run the executable at execPath as a child process, and resolve a promise + * to the executable's stdout output as a string. Reject the promise if + * anything is printed to stderr, or if the child process exits with a + * non-zero exit code. + * @param execPath Executable path + * @param args Command-line argument for the executable + */ +export async function getSubprocessStdout( + execPath: string, + args: string[], +): Promise { + const child = spawn(execPath, args); + return new Promise((resolve, reject) => { + let stdout = ''; + child.stdout.on('error', reject); + child.stderr.on('error', reject); + child.stdout.on('data', (data: Buffer) => { + try { + stdout = data.toString(); + } catch (err) { + reject(err); + } + }); + child.stderr.on('data', (data: Buffer) => { + try { + const stderr = data.toString(); + + // ignore any debug lines, but ensure that we parse + // every line provided to the stderr stream + const lines = _.filter( + stderr.trim().split(/\r?\n/), + line => !line.startsWith('[debug]'), + ); + if (lines.length > 0) { + reject( + new Error(`"${execPath}": non-empty stderr "${lines.join('\n')}"`), + ); + } + } catch (err) { + reject(err); + } + }); + child.on('exit', (code: number) => { + if (code) { + reject(new Error(`"${execPath}": non-zero exit code "${code}"`)); + } else { + resolve(stdout); + } + }); + }); +} + +/** + * Error handling wrapper around the npm `which` package: + * "Like the unix which utility. Finds the first instance of a specified + * executable in the PATH environment variable. Does not cache the results, + * so hash -r is not needed when the PATH changes." + * + * @param program Basename of a program, for example 'ssh' + * @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE' + */ +export async function which(program: string): Promise { + const whichMod = await import('which'); + let programPath: string; + try { + programPath = await whichMod(program); + } catch (err) { + if (err.code === 'ENOENT') { + throw new Error(`'${program}' program not found. Is it installed?`); + } + throw err; + } + return programPath; +} + +/** + * Call which(programName) and spawn() with the given arguments. Throw an error + * if the process exit code is not zero. + */ +export async function whichSpawn( + programName: string, + args: string[], +): Promise { + const program = await which(programName); + let error: Error | undefined; + let exitCode: number | undefined; + try { + exitCode = await new Promise((resolve, reject) => { + try { + spawn(program, args, { stdio: 'inherit' }) + .on('error', reject) + .on('close', resolve); + } catch (err) { + reject(err); + } + }); + } catch (err) { + error = err; + } + if (error || exitCode) { + const msg = [ + `${programName} failed with exit code ${exitCode}:`, + `"${program}" [${args}]`, + ]; + if (error) { + msg.push(`${error}`); + } + throw new Error(msg.join('\n')); + } +} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 68a8c2ba..72a3fed3 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2702,9 +2702,9 @@ "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "catch-uncommitted": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/catch-uncommitted/-/catch-uncommitted-1.3.0.tgz", - "integrity": "sha512-JJrlxvOX8mLEmQ7zk/w+su70FQeuTkRH9OYqWg8df3YLjz+rEkHKlWx0+C3/jjWZxRSrB1JBVhS5MhXJ3VhU1A==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/catch-uncommitted/-/catch-uncommitted-1.4.0.tgz", + "integrity": "sha512-xrLMj7iYrMc3TXSLsRO9tTxfcWEUICGCDZm+WI40WznxLp/+mVE8v4RxipC/ufL5TDfAYAe1ppu5VURBN990SQ==", "dev": true }, "chai": { diff --git a/package.json b/package.json index 1d83e397..87d9806e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "scripts": { "postinstall": "patch-package", "prebuild": "rimraf build/ build-bin/", - "build": "npm run build:src", + "build": "npm run build:src && npm run catch-uncommitted", "build:src": "npm run prettify && npm run lint && npm run build:fast && npm run build:doc", "build:fast": "gulp build && tsc", "build:doc": "mkdirp doc/ && ts-node --type-check -P automation/tsconfig.json automation/capitanodoc/index.ts > doc/cli.markdown", @@ -52,6 +52,7 @@ "pretest": "npm run build", "test": "mocha --timeout 6000 -r ts-node/register \"tests/**/*.spec.ts\"", "test:fast": "npm run build:fast && npm run test", + "catch-uncommitted": "ts-node --type-check -P automation/tsconfig.json automation/run.ts catch-uncommitted", "ci": "npm run test && catch-uncommitted", "watch": "gulp watch", "prettify": "prettier --write \"{lib,tests,automation,typings}/**/*.[tj]s\" --config ./node_modules/resin-lint/config/.prettierrc", @@ -124,7 +125,7 @@ "@types/tar-stream": "1.6.0", "@types/through2": "2.0.33", "@types/which": "^1.3.2", - "catch-uncommitted": "^1.3.0", + "catch-uncommitted": "^1.4.0", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", "ent": "^2.2.0",