From 0738dd152067d7aabdb1b424cc7737a3a70ca3bd Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Sat, 15 Feb 2020 20:18:00 +0000 Subject: [PATCH] Add and refactor tests for push/build/deploy commands (docker-compose) Change-type: patch --- .gitattributes | 3 +- tests/commands/build.spec.ts | 193 +++++++++------ tests/commands/deploy.spec.ts | 74 +++--- tests/commands/push.spec.ts | 202 ++++++++------- tests/docker-build.ts | 232 ++++++++++++++++++ tests/helpers.ts | 127 ++-------- .../docker-compose/basic/docker-compose.yml | 14 ++ .../basic/service1/Dockerfile.template | 3 + .../docker-compose/basic/service1/file1.sh | 2 + .../basic/service2/Dockerfile-alt | 1 + .../basic/service2/file2-crlf.sh | 2 + 11 files changed, 525 insertions(+), 328 deletions(-) create mode 100644 tests/docker-build.ts create mode 100644 tests/test-data/projects/docker-compose/basic/docker-compose.yml create mode 100644 tests/test-data/projects/docker-compose/basic/service1/Dockerfile.template create mode 100644 tests/test-data/projects/docker-compose/basic/service1/file1.sh create mode 100644 tests/test-data/projects/docker-compose/basic/service2/Dockerfile-alt create mode 100644 tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh diff --git a/.gitattributes b/.gitattributes index 1b0fba13..04020ff1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,5 +7,6 @@ # lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows doc/cli.markdown text eol=lf -# crlf for the for the windows-crlf test file +# crlf for the eol conversion test files +tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf diff --git a/tests/commands/build.spec.ts b/tests/commands/build.spec.ts index 7c1658a4..ca719539 100644 --- a/tests/commands/build.spec.ts +++ b/tests/commands/build.spec.ts @@ -18,44 +18,42 @@ // tslint:disable-next-line:no-var-requires require('../config-tests'); // required for side effects -import { expect } from 'chai'; +import * as _ from 'lodash'; import { fs } from 'mz'; import * as path from 'path'; -import { URL } from 'url'; import { BalenaAPIMock } from '../balena-api-mock'; -import { DockerMock, dockerResponsePath } from '../docker-mock'; import { - cleanOutput, + ExpectedTarStreamFiles, + ExpectedTarStreamFilesByService, expectStreamNoCRLF, - inspectTarStream, - runCommand, - TarStreamFiles, -} from '../helpers'; + testDockerBuildStream, +} from '../docker-build'; +import { DockerMock, dockerResponsePath } from '../docker-mock'; const repoPath = path.normalize(path.join(__dirname, '..', '..')); const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); -const expectedResponses = { +const commonResponseLines: { [key: string]: string[] } = { 'build-POST.json': [ '[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!', ], }; +const commonQueryParams = [ + ['t', '${tag}'], + ['buildargs', '{}'], + ['labels', ''], +]; + describe('balena build', function() { let api: BalenaAPIMock; let docker: DockerMock; - - const commonQueryParams = [ - ['t', 'basic_main'], - ['buildargs', '{}'], - ['labels', ''], - ]; + const isWindows = process.platform === 'win32'; this.beforeEach(() => { api = new BalenaAPIMock(); @@ -65,7 +63,6 @@ describe('balena build', function() { docker.expectGetPing(); docker.expectGetInfo(); docker.expectGetVersion(); - docker.expectGetImages(); }); this.afterEach(() => { @@ -76,7 +73,7 @@ describe('balena build', function() { it('should create the expected tar stream (single container)', async () => { const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); - const expectedFiles: TarStreamFiles = { + const expectedFiles: ExpectedTarStreamFiles = { 'src/start.sh': { fileSize: 89, type: 'file' }, 'src/windows-crlf.sh': { fileSize: 70, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' }, @@ -87,55 +84,43 @@ describe('balena build', function() { path.join(dockerResponsePath, responseFilename), 'utf8', ); - - docker.expectPostBuild({ - tag: 'basic_main', - responseCode: 200, - responseBody, - checkURI: async (uri: string) => { - const url = new URL(uri, 'http://test.net/'); - const queryParams = Array.from(url.searchParams.entries()); - expect(queryParams).to.have.deep.members(commonQueryParams); - }, - checkBuildRequestBody: (buildRequestBody: string) => - inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), - }); - - const { out, err } = await runCommand( - `build ${projectPath} --deviceType nuc --arch amd64`, - ); - - const extraLines = [ + const expectedResponseLines = [ + ...commonResponseLines[responseFilename], `[Info] Creating default composition with source: ${projectPath}`, + '[Build] main Image size: 1.14 MB', ]; - if (process.platform === 'win32') { - extraLines.push( + if (isWindows) { + expectedResponseLines.push( `[Warn] CRLF (Windows) line endings detected in file: ${path.join( projectPath, 'src', 'windows-crlf.sh', )}`, + '[Warn] Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.', ); } - expect(err).to.have.members([]); - expect( - cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), - ).to.include.members([ - ...expectedResponses[responseFilename], - ...extraLines, - ]); + await testDockerBuildStream({ + commandLine: `build ${projectPath} --deviceType nuc --arch amd64`, + dockerMock: docker, + expectedFilesByService: { main: expectedFiles }, + expectedQueryParamsByService: { main: commonQueryParams }, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + services: ['main'], + }); }); it('should create the expected tar stream (single container, --convert-eol)', async () => { - const windows = process.platform === 'win32'; const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); - const expectedFiles: TarStreamFiles = { + const expectedFiles: ExpectedTarStreamFiles = { 'src/start.sh': { fileSize: 89, type: 'file' }, 'src/windows-crlf.sh': { - fileSize: windows ? 68 : 70, + fileSize: isWindows ? 68 : 70, + testStream: isWindows ? expectStreamNoCRLF : undefined, type: 'file', - testStream: windows ? expectStreamNoCRLF : undefined, }, Dockerfile: { fileSize: 88, type: 'file' }, 'Dockerfile-alt': { fileSize: 30, type: 'file' }, @@ -145,29 +130,13 @@ describe('balena build', function() { path.join(dockerResponsePath, responseFilename), 'utf8', ); - - docker.expectPostBuild({ - tag: 'basic_main', - responseCode: 200, - responseBody, - checkURI: async (uri: string) => { - const url = new URL(uri, 'http://test.net/'); - const queryParams = Array.from(url.searchParams.entries()); - expect(queryParams).to.have.deep.members(commonQueryParams); - }, - checkBuildRequestBody: (buildRequestBody: string) => - inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), - }); - - const { out, err } = await runCommand( - `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`, - ); - - const extraLines = [ + const expectedResponseLines = [ + ...commonResponseLines[responseFilename], `[Info] Creating default composition with source: ${projectPath}`, + '[Build] main Image size: 1.14 MB', ]; - if (windows) { - extraLines.push( + if (isWindows) { + expectedResponseLines.push( `[Info] Converting line endings CRLF -> LF for file: ${path.join( projectPath, 'src', @@ -176,12 +145,80 @@ describe('balena build', function() { ); } - expect(err).to.have.members([]); - expect( - cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), - ).to.include.members([ - ...expectedResponses[responseFilename], - ...extraLines, - ]); + await testDockerBuildStream({ + commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`, + dockerMock: docker, + expectedFilesByService: { main: expectedFiles }, + expectedQueryParamsByService: { main: commonQueryParams }, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + services: ['main'], + }); + }); + + it('should create the expected tar stream (docker-compose)', async () => { + const projectPath = path.join(projectsPath, 'docker-compose', 'basic'); + const service1Dockerfile = ( + await fs.readFile( + path.join(projectPath, 'service1', 'Dockerfile.template'), + 'utf8', + ) + ).replace('%%BALENA_MACHINE_NAME%%', 'nuc'); + const expectedFilesByService: ExpectedTarStreamFilesByService = { + service1: { + Dockerfile: { + contents: service1Dockerfile, + fileSize: service1Dockerfile.length, + type: 'file', + }, + 'Dockerfile.template': { fileSize: 144, type: 'file' }, + 'file1.sh': { fileSize: 12, type: 'file' }, + }, + service2: { + 'Dockerfile-alt': { fileSize: 40, type: 'file' }, + 'file2-crlf.sh': { + fileSize: isWindows ? 12 : 14, + testStream: isWindows ? expectStreamNoCRLF : undefined, + type: 'file', + }, + }, + }; + const responseFilename = 'build-POST.json'; + const responseBody = await fs.readFile( + path.join(dockerResponsePath, responseFilename), + 'utf8', + ); + const expectedQueryParamsByService = { + service1: commonQueryParams, + service2: [...commonQueryParams, ['dockerfile', 'Dockerfile-alt']], + }; + const expectedResponseLines: string[] = [ + ...commonResponseLines[responseFilename], + `[Build] service1 Image size: 1.14 MB`, + `[Build] service2 Image size: 1.14 MB`, + ]; + if (isWindows) { + expectedResponseLines.push( + `[Info] Converting line endings CRLF -> LF for file: ${path.join( + projectPath, + 'service2', + 'file2-crlf.sh', + )}`, + ); + } + + await testDockerBuildStream({ + commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol`, + dockerMock: docker, + expectedFilesByService, + expectedQueryParamsByService, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + services: ['service1', 'service2'], + }); }); }); diff --git a/tests/commands/deploy.spec.ts b/tests/commands/deploy.spec.ts index b03b4ae3..1869ea72 100644 --- a/tests/commands/deploy.spec.ts +++ b/tests/commands/deploy.spec.ts @@ -18,23 +18,17 @@ // tslint:disable-next-line:no-var-requires require('../config-tests'); // required for side effects -import { expect } from 'chai'; import { fs } from 'mz'; import * as path from 'path'; -import { URL } from 'url'; import { BalenaAPIMock } from '../balena-api-mock'; +import { ExpectedTarStreamFiles, testDockerBuildStream } from '../docker-build'; import { DockerMock, dockerResponsePath } 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 expectedResponses = { + +const commonResponseLines = { 'build-POST.json': [ '[Info] Building for armv7hf/raspberrypi3', '[Info] Docker Desktop detected (daemon architecture: "x86_64")', @@ -49,15 +43,16 @@ const expectedResponses = { ], }; +const commonQueryParams = [ + ['t', '${tag}'], + ['buildargs', '{}'], + ['labels', ''], +]; + describe('balena deploy', function() { let api: BalenaAPIMock; let docker: DockerMock; - - const commonQueryParams = [ - ['t', 'basic_main'], - ['buildargs', '{}'], - ['labels', ''], - ]; + const isWindows = process.platform === 'win32'; this.beforeEach(() => { api = new BalenaAPIMock(); @@ -80,8 +75,7 @@ describe('balena deploy', function() { docker.expectGetPing(); docker.expectGetInfo(); - docker.expectGetVersion(); - docker.expectGetImages({ persist: true }); + docker.expectGetVersion({ persist: true }); docker.expectPostImagesTag(); docker.expectPostImagesPush(); docker.expectDeleteImages(); @@ -95,7 +89,7 @@ describe('balena deploy', function() { it('should create the expected --build tar stream (single container)', async () => { const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); - const expectedFiles: TarStreamFiles = { + const expectedFiles: ExpectedTarStreamFiles = { 'src/start.sh': { fileSize: 89, type: 'file' }, 'src/windows-crlf.sh': { fileSize: 70, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' }, @@ -106,43 +100,31 @@ describe('balena deploy', function() { path.join(dockerResponsePath, responseFilename), 'utf8', ); - - docker.expectPostBuild({ - tag: 'basic_main', - responseCode: 200, - responseBody, - checkURI: async (uri: string) => { - const url = new URL(uri, 'http://test.net/'); - const queryParams = Array.from(url.searchParams.entries()); - expect(queryParams).to.have.deep.members(commonQueryParams); - }, - checkBuildRequestBody: (buildRequestBody: string) => - inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), - }); - - const { out, err } = await runCommand( - `deploy testApp --build --source ${projectPath}`, - ); - - const extraLines = [ + const expectedResponseLines = [ + ...commonResponseLines[responseFilename], `[Info] Creating default composition with source: ${projectPath}`, ]; - if (process.platform === 'win32') { - extraLines.push( + if (isWindows) { + expectedResponseLines.push( `[Warn] CRLF (Windows) line endings detected in file: ${path.join( projectPath, 'src', 'windows-crlf.sh', )}`, + '[Warn] Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.', ); } - expect(err).to.have.members([]); - expect( - cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), - ).to.include.members([ - ...expectedResponses[responseFilename], - ...extraLines, - ]); + await testDockerBuildStream({ + commandLine: `deploy testApp --build --source ${projectPath}`, + dockerMock: docker, + expectedFilesByService: { main: expectedFiles }, + expectedQueryParamsByService: { main: commonQueryParams }, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + services: ['main'], + }); }); }); diff --git a/tests/commands/push.spec.ts b/tests/commands/push.spec.ts index 55f5ac72..e9bff5d3 100644 --- a/tests/commands/push.spec.ts +++ b/tests/commands/push.spec.ts @@ -18,25 +18,21 @@ // tslint:disable-next-line:no-var-requires require('../config-tests'); // required for side effects -import { expect } from 'chai'; import { fs } from 'mz'; import * as path from 'path'; -import { URL } from 'url'; import { BalenaAPIMock } from '../balena-api-mock'; import { BuilderMock, builderResponsePath } from '../builder-mock'; import { - cleanOutput, + ExpectedTarStreamFiles, expectStreamNoCRLF, - inspectTarStream, - runCommand, - TarStreamFiles, -} from '../helpers'; + testPushBuildStream, +} from '../docker-build'; const repoPath = path.normalize(path.join(__dirname, '..', '..')); const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects'); -const expectedResponses = { +const commonResponseLines = { 'build-POST-v3.json': [ '[Info] Starting build for testApp, user gh_user', '[Info] Dashboard link: https://dashboard.balena-cloud.com/apps/1301645/devices', @@ -66,28 +62,22 @@ const expectedResponses = { '[Info] ├─────────┼────────────┼────────────┤', '[Info] │ main │ 1.32 MB │ 11 seconds │', '[Info] └─────────┴────────────┴────────────┘', - '[Info] Build finished in 20 seconds', ], }; -function tweakOutput(out: string[]): string[] { - return cleanOutput(out).map(line => - line.replace(/\s{2,}/g, ' ').replace(/in \d+? seconds/, 'in 20 seconds'), - ); -} +const commonQueryParams = [ + ['owner', 'bob'], + ['app', 'testApp'], + ['dockerfilePath', ''], + ['emulated', 'false'], + ['nocache', 'false'], + ['headless', 'false'], +]; describe('balena push', function() { let api: BalenaAPIMock; let builder: BuilderMock; - - const commonQueryParams = [ - ['owner', 'bob'], - ['app', 'testApp'], - ['dockerfilePath', ''], - ['emulated', 'false'], - ['nocache', 'false'], - ['headless', 'false'], - ]; + const isWindows = process.platform === 'win32'; this.beforeEach(() => { api = new BalenaAPIMock(); @@ -105,7 +95,7 @@ describe('balena push', function() { it('should create the expected tar stream (single container)', async () => { const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); - const expectedFiles: TarStreamFiles = { + const expectedFiles: ExpectedTarStreamFiles = { 'src/start.sh': { fileSize: 89, type: 'file' }, 'src/windows-crlf.sh': { fileSize: 70, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' }, @@ -116,44 +106,33 @@ describe('balena push', function() { path.join(builderResponsePath, responseFilename), 'utf8', ); - - builder.expectPostBuild({ - responseCode: 200, - responseBody, - checkURI: async (uri: string) => { - const url = new URL(uri, 'http://test.net/'); - const queryParams = Array.from(url.searchParams.entries()); - expect(queryParams).to.have.deep.members(commonQueryParams); - }, - checkBuildRequestBody: (buildRequestBody: string | Buffer) => - inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), - }); - - const { out, err } = await runCommand( - `push testApp --source ${projectPath}`, - ); - - const extraLines = []; - if (process.platform === 'win32') { - extraLines.push( + const expectedResponseLines = [...commonResponseLines[responseFilename]]; + if (isWindows) { + expectedResponseLines.push( `[Warn] CRLF (Windows) line endings detected in file: ${path.join( projectPath, 'src', 'windows-crlf.sh', )}`, + '[Warn] Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.', ); } - expect(err).to.have.members([]); - expect(tweakOutput(out)).to.include.members([ - ...expectedResponses[responseFilename], - ...extraLines, - ]); + await testPushBuildStream({ + builderMock: builder, + commandLine: `push testApp --source ${projectPath}`, + expectedFiles, + expectedQueryParams: commonQueryParams, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + }); }); it('should create the expected tar stream (alternative Dockerfile)', async () => { const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); - const expectedFiles: TarStreamFiles = { + const expectedFiles: ExpectedTarStreamFiles = { 'src/start.sh': { fileSize: 89, type: 'file' }, 'src/windows-crlf.sh': { fileSize: 70, type: 'file' }, Dockerfile: { fileSize: 88, type: 'file' }, @@ -164,44 +143,30 @@ describe('balena push', function() { path.join(builderResponsePath, responseFilename), 'utf8', ); + const expectedQueryParams = commonQueryParams.map(i => + i[0] === 'dockerfilePath' ? ['dockerfilePath', 'Dockerfile-alt'] : i, + ); - builder.expectPostBuild({ - responseCode: 200, + await testPushBuildStream({ + builderMock: builder, + commandLine: `push testApp --source ${projectPath} --dockerfile Dockerfile-alt`, + expectedFiles, + expectedQueryParams, + expectedResponseLines: commonResponseLines[responseFilename], + projectPath, responseBody, - checkURI: async (uri: string) => { - const url = new URL(uri, 'http://test.net/'); - const queryParams = Array.from(url.searchParams.entries()); - expect(queryParams).to.have.deep.members( - commonQueryParams.map(i => - i[0] === 'dockerfilePath' - ? ['dockerfilePath', 'Dockerfile-alt'] - : i, - ), - ); - }, - checkBuildRequestBody: (buildRequestBody: string | Buffer) => - inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), + responseCode: 200, }); - - const { out, err } = await runCommand( - `push testApp --source ${projectPath} --dockerfile Dockerfile-alt`, - ); - - expect(err).to.have.members([]); - expect(tweakOutput(out)).to.include.members( - expectedResponses[responseFilename], - ); }); it('should create the expected tar stream (single container, --convert-eol)', async () => { - const windows = process.platform === 'win32'; const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic'); - const expectedFiles: TarStreamFiles = { + const expectedFiles: ExpectedTarStreamFiles = { 'src/start.sh': { fileSize: 89, type: 'file' }, 'src/windows-crlf.sh': { - fileSize: windows ? 68 : 70, + fileSize: isWindows ? 68 : 70, type: 'file', - testStream: windows ? expectStreamNoCRLF : undefined, + testStream: isWindows ? expectStreamNoCRLF : undefined, }, Dockerfile: { fileSize: 88, type: 'file' }, 'Dockerfile-alt': { fileSize: 30, type: 'file' }, @@ -211,26 +176,9 @@ describe('balena push', function() { path.join(builderResponsePath, responseFilename), 'utf8', ); - - builder.expectPostBuild({ - responseCode: 200, - responseBody, - checkURI: async (uri: string) => { - const url = new URL(uri, 'http://test.net/'); - const queryParams = Array.from(url.searchParams.entries()); - expect(queryParams).to.have.deep.members(commonQueryParams); - }, - checkBuildRequestBody: (buildRequestBody: string | Buffer) => - inspectTarStream(buildRequestBody, expectedFiles, projectPath, expect), - }); - - const { out, err } = await runCommand( - `push testApp --source ${projectPath} --convert-eol`, - ); - - const extraLines = []; - if (windows) { - extraLines.push( + const expectedResponseLines = [...commonResponseLines[responseFilename]]; + if (isWindows) { + expectedResponseLines.push( `[Info] Converting line endings CRLF -> LF for file: ${path.join( projectPath, 'src', @@ -239,10 +187,58 @@ describe('balena push', function() { ); } - expect(err).to.have.members([]); - expect(tweakOutput(out)).to.include.members([ - ...expectedResponses[responseFilename], - ...extraLines, - ]); + await testPushBuildStream({ + builderMock: builder, + commandLine: `push testApp --source ${projectPath} --convert-eol`, + expectedFiles, + expectedQueryParams: commonQueryParams, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + }); + }); + + it('should create the expected tar stream (docker-compose)', async () => { + const projectPath = path.join(projectsPath, 'docker-compose', 'basic'); + const expectedFiles: ExpectedTarStreamFiles = { + 'docker-compose.yml': { fileSize: 245, type: 'file' }, + 'service1/Dockerfile.template': { fileSize: 144, type: 'file' }, + 'service1/file1.sh': { fileSize: 12, type: 'file' }, + 'service2/Dockerfile-alt': { fileSize: 40, type: 'file' }, + 'service2/file2-crlf.sh': { + fileSize: isWindows ? 12 : 14, + testStream: isWindows ? expectStreamNoCRLF : undefined, + type: 'file', + }, + }; + const responseFilename = 'build-POST-v3.json'; + const responseBody = await fs.readFile( + path.join(builderResponsePath, responseFilename), + 'utf8', + ); + const expectedResponseLines: string[] = [ + ...commonResponseLines[responseFilename], + ]; + if (isWindows) { + expectedResponseLines.push( + `[Info] Converting line endings CRLF -> LF for file: ${path.join( + projectPath, + 'service2', + 'file2-crlf.sh', + )}`, + ); + } + + await testPushBuildStream({ + builderMock: builder, + commandLine: `push testApp --source ${projectPath} --convert-eol`, + expectedFiles, + expectedQueryParams: commonQueryParams, + expectedResponseLines, + projectPath, + responseBody, + responseCode: 200, + }); }); }); diff --git a/tests/docker-build.ts b/tests/docker-build.ts new file mode 100644 index 00000000..e42362fe --- /dev/null +++ b/tests/docker-build.ts @@ -0,0 +1,232 @@ +/** + * @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 { stripIndent } from 'common-tags'; +import * as _ from 'lodash'; +import { fs } from 'mz'; +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 { URL } from 'url'; + +import { BuilderMock } from './builder-mock'; +import { DockerMock } from './docker-mock'; +import { cleanOutput, fillTemplateArray, runCommand } from './helpers'; + +export interface ExpectedTarStreamFile { + contents?: string; + fileSize: number; + testStream?: ( + header: tar.Headers, + stream: Readable, + expected?: ExpectedTarStreamFile, + ) => Promise; + type: tar.Headers['type']; +} + +export interface ExpectedTarStreamFiles { + [filePath: string]: ExpectedTarStreamFile; +} + +export interface ExpectedTarStreamFilesByService { + [service: string]: ExpectedTarStreamFiles; +} + +/** + * 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 + */ +export async function inspectTarStream( + tarRequestBody: string | Buffer, + expectedFiles: ExpectedTarStreamFiles, + projectPath: string, +): Promise { + // string to stream: https://stackoverflow.com/a/22085851 + const sourceTarStream = new Readable(); + sourceTarStream._read = () => undefined; + sourceTarStream.push(tarRequestBody); + sourceTarStream.push(null); + + const found: ExpectedTarStreamFiles = await new Promise((resolve, reject) => { + const foundFiles: ExpectedTarStreamFiles = {}; + 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 expected = expectedFiles[header.name]; + if (expected && expected.testStream) { + await expected.testStream(header, stream, expected); + } else { + await defaultTestStream(header, stream, expected, projectPath); + } + } + } catch (err) { + reject(err); + } + next(); + }, + ); + extract.once('finish', () => { + resolve(foundFiles); + }); + sourceTarStream.on('error', reject); + sourceTarStream.pipe(extract); + }); + + expect(found).to.deep.equal( + _.mapValues(expectedFiles, v => _.omit(v, 'testStream', 'contents')), + ); +} + +/** Check that a tar stream entry matches the project contents in the filesystem */ +async function defaultTestStream( + header: tar.Headers, + stream: Readable, + expected: ExpectedTarStreamFile | undefined, + projectPath: string, +): Promise { + let expectedContents: Buffer | undefined; + if (expected?.contents) { + expectedContents = Buffer.from(expected.contents); + } + const [buf, buf2] = await Promise.all([ + streamToBuffer(stream), + expectedContents || + fs.readFile(path.join(projectPath, PathUtils.toNativePath(header.name))), + ]); + const msg = stripIndent` + contents mismatch for tar stream entry "${header.name}" + stream length=${buf.length}, filesystem length=${buf2.length}`; + + expect(buf.equals(buf2), msg).to.be.true; +} + +/** Test a tar stream entry for the absence of Windows CRLF line breaks */ +export async function expectStreamNoCRLF( + _header: tar.Headers, + stream: Readable, +): Promise { + const chai = await import('chai'); + const buf = await streamToBuffer(stream); + await chai.expect(buf.includes('\r\n')).to.be.false; +} + +/** + * Common test logic for the 'build' and 'deploy' commands + */ +export async function testDockerBuildStream(o: { + commandLine: string; + dockerMock: DockerMock; + expectedFilesByService: ExpectedTarStreamFilesByService; + expectedQueryParamsByService: { [service: string]: string[][] }; + expectedResponseLines: string[]; + projectPath: string; + responseCode: number; + responseBody: string; + services: string[]; // e.g. ['main'] or ['service1', 'service2'] +}) { + const expectedResponseLines = fillTemplateArray(o.expectedResponseLines, o); + + for (const service of o.services) { + // tagPrefix is, for example, 'myApp' if the path is 'path/to/myApp' + const tagPrefix = o.projectPath.split(path.sep).pop(); + const tag = `${tagPrefix}_${service}`; + const expectedFiles = o.expectedFilesByService[service]; + const expectedQueryParams = fillTemplateArray( + o.expectedQueryParamsByService[service], + _.assign({ tag }, o), + ); + const projectPath = + service === 'main' ? o.projectPath : path.join(o.projectPath, service); + + o.dockerMock.expectPostBuild( + _.assign({}, o, { + checkURI: async (uri: string) => { + const url = new URL(uri, 'http://test.net/'); + const queryParams = Array.from(url.searchParams.entries()); + expect(queryParams).to.have.deep.members(expectedQueryParams); + }, + checkBuildRequestBody: (buildRequestBody: string) => + inspectTarStream(buildRequestBody, expectedFiles, projectPath), + tag, + }), + ); + o.dockerMock.expectGetImages(); + } + + const { out, err } = await runCommand(o.commandLine); + + expect(err).to.be.empty; + expect( + cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), + ).to.include.members(expectedResponseLines); +} + +/** + * Common test logic for the 'push' command + */ +export async function testPushBuildStream(o: { + commandLine: string; + builderMock: BuilderMock; + expectedFiles: ExpectedTarStreamFiles; + expectedQueryParams: string[][]; + expectedResponseLines: string[]; + projectPath: string; + responseCode: number; + responseBody: string; +}) { + const expectedQueryParams = fillTemplateArray(o.expectedQueryParams, o); + const expectedResponseLines = fillTemplateArray(o.expectedResponseLines, o); + + o.builderMock.expectPostBuild( + _.assign({}, o, { + checkURI: async (uri: string) => { + const url = new URL(uri, 'http://test.net/'); + const queryParams = Array.from(url.searchParams.entries()); + expect(queryParams).to.have.deep.members(expectedQueryParams); + }, + checkBuildRequestBody: (buildRequestBody: string) => + inspectTarStream(buildRequestBody, o.expectedFiles, o.projectPath), + }), + ); + + const { out, err } = await runCommand(o.commandLine); + + expect(err).to.be.empty; + expect( + cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')), + ).to.include.members(expectedResponseLines); +} diff --git a/tests/helpers.ts b/tests/helpers.ts index c96101e0..d61ab465 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -18,16 +18,10 @@ // tslint:disable-next-line:no-var-requires require('./config-tests'); // required for side effects -import { stripIndent } from 'common-tags'; 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'; @@ -114,101 +108,34 @@ 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']; - testStream?: (header: tar.Headers, stream: Readable) => Promise; - }; +/** + * Dynamic template string resolution. + * Usage example: + * const templateString = 'hello ${name}!'; + * const templateVars = { name: 'world' }; + * console.log( fillTemplate(templateString, templateVars) ); + * // hello world! + */ +export function fillTemplate( + templateString: string, + templateVars: object, +): string { + const escaped = templateString.replace(/\\/g, '\\\\').replace(/`/g, '\\`'); + const resolved = new Function( + ...Object.keys(templateVars), + `return \`${escaped}\`;`, + ).call(null, ...Object.values(templateVars)); + const unescaped = resolved.replace(/\\`/g, '`').replace(/\\\\/g, '\\'); + return unescaped; } -/** - * 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 expected = expectedFiles[header.name]; - if (expected && expected.testStream) { - await expected.testStream(header, stream); - } else { - await defaultTestStream(header, stream, projectPath, expect); - } - } - } catch (err) { - reject(err); - } - next(); - }, - ); - extract.once('finish', () => { - resolve(foundFiles); - }); - sourceTarStream.on('error', reject); - sourceTarStream.pipe(extract); - }); - - expect(found).to.deep.equal( - _.mapValues(expectedFiles, v => _.omit(v, 'testStream')), +export function fillTemplateArray( + templateStringArray: Array, + templateVars: object, +) { + return templateStringArray.map(i => + Array.isArray(i) + ? fillTemplateArray(i, templateVars) + : fillTemplate(i, templateVars), ); } - -/** Check that a tar stream entry matches the project contents in the filesystem */ -async function defaultTestStream( - header: tar.Headers, - stream: Readable, - projectPath: string, - expect: Chai.ExpectStatic, -): Promise { - const [buf, buf2] = await Promise.all([ - streamToBuffer(stream), - fs.readFile(path.join(projectPath, PathUtils.toNativePath(header.name))), - ]); - const msg = stripIndent` - contents mismatch for tar stream entry "${header.name}" - stream length=${buf.length}, filesystem length=${buf2.length}`; - expect(buf.equals(buf2), msg).to.be.true; -} - -/** Test a tar stream entry for the absence of Windows CRLF line breaks */ -export async function expectStreamNoCRLF( - _header: tar.Headers, - stream: Readable, -): Promise { - const chai = await import('chai'); - const buf = await streamToBuffer(stream); - await chai.expect(buf.includes('\r\n')).to.be.false; -} diff --git a/tests/test-data/projects/docker-compose/basic/docker-compose.yml b/tests/test-data/projects/docker-compose/basic/docker-compose.yml new file mode 100644 index 00000000..6bb6d970 --- /dev/null +++ b/tests/test-data/projects/docker-compose/basic/docker-compose.yml @@ -0,0 +1,14 @@ +version: '2' +volumes: + resin-data: +services: + service1: + volumes: + - 'resin-data:/data' + build: ./service1 + service2: + volumes: + - 'resin-data:/data' + build: + context: ./service2 + dockerfile: Dockerfile-alt diff --git a/tests/test-data/projects/docker-compose/basic/service1/Dockerfile.template b/tests/test-data/projects/docker-compose/basic/service1/Dockerfile.template new file mode 100644 index 00000000..f3cf8768 --- /dev/null +++ b/tests/test-data/projects/docker-compose/basic/service1/Dockerfile.template @@ -0,0 +1,3 @@ +FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine +COPY ./file1.sh / +CMD i=1; while :; do echo "service1 $i $(uname -a)"; sleep 10; i=$((i+1)); done diff --git a/tests/test-data/projects/docker-compose/basic/service1/file1.sh b/tests/test-data/projects/docker-compose/basic/service1/file1.sh new file mode 100644 index 00000000..c0d0fb45 --- /dev/null +++ b/tests/test-data/projects/docker-compose/basic/service1/file1.sh @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/tests/test-data/projects/docker-compose/basic/service2/Dockerfile-alt b/tests/test-data/projects/docker-compose/basic/service2/Dockerfile-alt new file mode 100644 index 00000000..68823a66 --- /dev/null +++ b/tests/test-data/projects/docker-compose/basic/service2/Dockerfile-alt @@ -0,0 +1 @@ +alternative Dockerfile (basic/service2) diff --git a/tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh b/tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh new file mode 100644 index 00000000..c0d0fb45 --- /dev/null +++ b/tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh @@ -0,0 +1,2 @@ +line1 +line2