Add tests for standalone executable via proxy server

Change-type: patch
This commit is contained in:
Paulo Castro 2020-06-15 23:53:04 +01:00
parent 2c01f8adee
commit 7e1d58546c
18 changed files with 617 additions and 117 deletions

View File

@ -12,7 +12,8 @@ The balena CLI is an open source project and your contribution is welcome!
In order to ease development:
* `npm run build:fast` skips some of the build steps for interactive testing, or
* `./bin/balena-dev` uses `ts-node/register` and `coffeescript/register` to transpile on the fly.
* `npm run test:source` skips testing the standalone zip packages (which is rather slow)
* `./bin/balena-dev` uses `ts-node/register` to transpile on the fly.
Before opening a PR, test your changes with `npm test`. Keep compatibility in mind, as the CLI is
meant to run on Linux, macOS and Windows. balena CI will run test code on all three platforms, but
@ -121,22 +122,15 @@ $ npm install # "cleanly" update the npm-shrinkwrap.json file
$ git add npm-shrinkwrap.json # add it for committing (solve merge errors)
```
## TypeScript vs CoffeeScript, and Capitano vs oclif
## TypeScript and oclif
The CLI was originally written in [CoffeeScript](https://coffeescript.org), but we decided to
migrate to [TypeScript](https://www.typescriptlang.org/) in order to take advantage of static
typing and formal programming interfaces. The migration is taking place gradually, as part of
maintenance work or the implementation of new features. The recommended way of making the
conversion is to first generate plain Javascript, for example using the command:
```
npx decaffeinate --use-js-modules file.coffee
```
Then manually convert plain Javascript to Typescript. There is also a ["Coffeescript Preview"
Visual Studio Code
extension](https://marketplace.visualstudio.com/items?itemName=drewbarrett.vscode-coffeescript-preview)
that you may find handy.
The CLI currently contains a mix of plain JavaScript and
[TypeScript](https://www.typescriptlang.org/) code. The goal is to have all code written in
Typescript, in order to take advantage of static typing and formal programming interfaces.
The migration towards Typescript is taking place gradually, as part of maintenance work or
the implementation of new features. Historically, the CLI was originally written in
[CoffeeScript](https://coffeescript.org), but all CoffeeScript code was migrated to either
Javascript or Typescript.
Similarly, [Capitano](https://github.com/balena-io/capitano) was originally adopted as the CLI's
framework, but later we decided to take advantage of [oclif](https://oclif.io/)'s features such
@ -230,3 +224,4 @@ gotchas to bear in mind:
`node_modules/balena-sdk/node_modules/balena-errors`
In the case of subclasses of `TypedError`, a string comparison may be used instead:
`error.name === 'BalenaApplicationNotFound'`

View File

@ -162,12 +162,21 @@ async function setupGlobalAgentProxy(
privateNoProxy.push(`172.${i}.*`);
}
// BALENARC_DO_PROXY is a list of entries to exclude from BALENARC_NO_PROXY
// (so "do proxy" takes precedence over "no proxy"). It is an undocumented
// feature/hack added to facilitate testing of the CLI's standalone executable
// through a local proxy server, by setting BALENARC_DO_PROXY="localhost,127.0.0.1"
// See also runCommandInSubprocess() function in `tests/helpers.ts`.
const doProxy = (process.env.BALENARC_DO_PROXY || '').split(',');
const env = process.env;
env.GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE = '';
env.NO_PROXY = [
...requiredNoProxy,
...(noProxy ? noProxy.split(',').filter(v => v) : privateNoProxy),
].join(',');
]
.filter(i => !doProxy.includes(i))
.join(',');
if (proxy) {
const proxyUrl: string =

View File

@ -208,6 +208,7 @@ export class DeviceAPI {
'Non-200 response from log streaming endpoint',
),
);
return;
}
res.socket.setKeepAlive(true, 1000);
if (os.platform() !== 'win32') {

View File

@ -101,7 +101,12 @@ async function getBuilderEndpoint(
});
// Note that using https (rather than http) is a requirement when using the
// --registry-secrets feature, as the secrets are not otherwise encrypted.
return `https://builder.${baseUrl}/v3/build?${args}`;
let builderUrl =
process.env.BALENARC_BUILDER_URL || `https://builder.${baseUrl}`;
if (builderUrl.endsWith('/')) {
builderUrl = builderUrl.slice(0, -1);
}
return `${builderUrl}/v3/build?${args}`;
}
export async function startRemoteBuild(build: RemoteBuild): Promise<void> {

83
npm-shrinkwrap.json generated
View File

@ -1050,6 +1050,15 @@
"integrity": "sha512-JPhCJHHu5/5HuQ78rmbrnCk+XlyxKuAopk+/8rP5MfAeL7KwiCH/gFFNtAqIr0/JFqWFk+jXWZjEWSP8dzGpMg==",
"dev": true
},
"@types/http-proxy": {
"version": "1.17.4",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz",
"integrity": "sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/inquirer": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-6.5.0.tgz",
@ -4153,31 +4162,45 @@
}
},
"cross-env": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-3.2.4.tgz",
"integrity": "sha1-ngWF8neGTtQhznVvgamA/w1piro=",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.2.tgz",
"integrity": "sha512-KZP/bMEOJEDCkDQAyRhu3RL2ZO/SUVrxQVI0G3YEQ+OLbRA3c6zgixe8Mq8a/z7+HKlNEjo8oiLUs8iRijY2Rw==",
"dev": true,
"requires": {
"cross-spawn": "^5.1.0",
"is-windows": "^1.0.0"
"cross-spawn": "^7.0.1"
},
"dependencies": {
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
"integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"requires": {
"lru-cache": "^4.0.1",
"shebang-command": "^1.2.0",
"which": "^1.2.9"
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
}
},
"which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"requires": {
"isexe": "^2.0.0"
"shebang-regex": "^3.0.0"
}
},
"shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true
}
}
},
@ -5863,6 +5886,12 @@
}
}
},
"eventemitter3": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz",
"integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==",
"dev": true
},
"execa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
@ -7581,6 +7610,17 @@
}
}
},
"http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dev": true,
"requires": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
}
},
"http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
@ -8974,6 +9014,7 @@
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
"dev": true,
"requires": {
"pseudomap": "^1.0.2",
"yallist": "^2.1.2"
@ -8982,7 +9023,8 @@
"yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
"dev": true
}
}
},
@ -13621,7 +13663,6 @@
"common-tags": "^1.4.0",
"concurrently": "^3.4.0",
"cpy-cli": "^1.0.1",
"cross-env": "^3.1.4",
"is-windows": "^1.0.0",
"mkdirp": "^0.5.1",
"ncp": "2.0.0",
@ -15782,6 +15823,12 @@
}
}
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"resin-bundle-resolve": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/resin-bundle-resolve/-/resin-bundle-resolve-4.3.0.tgz",

View File

@ -56,7 +56,10 @@
"package": "npm run build:fast && npm run build:standalone && npm run build:installer",
"release": "ts-node --transpile-only automation/run.ts release",
"pretest": "npm run build",
"test": "mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/*.spec.ts\"",
"test": "npm run test:source && npm run test:standalone",
"test:source": "mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/*.spec.ts\"",
"test:standalone": "npm run build:standalone && cross-env BALENA_CLI_TEST_TYPE=standalone npm run test:source",
"test:standalone:fast": "cross-env BALENA_CLI_TEST_TYPE=standalone npm run test:source",
"test:fast": "npm run build:fast && mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/*.spec.ts\"",
"test:only": "npm run build:fast && mocha --timeout 6000 -r ts-node/register/transpile-only \"tests/**/${npm_config_test}.spec.ts\"",
"catch-uncommitted": "ts-node --transpile-only automation/run.ts catch-uncommitted",
@ -117,6 +120,7 @@
"@types/fs-extra": "^8.1.0",
"@types/global-agent": "^2.1.0",
"@types/global-tunnel-ng": "^2.1.0",
"@types/http-proxy": "^1.17.4",
"@types/intercept-stdout": "^0.1.0",
"@types/is-root": "^2.1.2",
"@types/js-yaml": "^3.12.3",
@ -148,11 +152,13 @@
"catch-uncommitted": "^1.5.0",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"cross-env": "^7.0.2",
"ent": "^2.2.0",
"filehound": "^1.17.4",
"fs-extra": "^8.0.1",
"gulp": "^4.0.1",
"gulp-inline-source": "^2.1.0",
"http-proxy": "^1.18.1",
"husky": "^4.2.5",
"intercept-stdout": "^0.1.2",
"mocha": "^6.2.3",

View File

@ -28,7 +28,7 @@ const jHeader = { 'Content-Type': 'application/json' };
export class BalenaAPIMock extends NockMock {
constructor() {
super('https://api.balena-cloud.com');
super(/api\.balena-cloud\.com/);
}
public expectGetApplication(opts: ScopeOpts = {}) {

View File

@ -27,7 +27,7 @@ export const builderResponsePath = path.normalize(
export class BuilderMock extends NockMock {
constructor() {
super('https://builder.balena-cloud.com');
super(/builder\.balena-cloud\.com/);
}
public expectPostBuild(opts: {

View File

@ -62,6 +62,9 @@ const commonComposeQueryParams = [
['labels', ''],
];
// "itSS" means "it() Skip Standalone"
const itSS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it.skip : it;
describe('balena build', function() {
let api: BalenaAPIMock;
let docker: DockerMock;
@ -135,7 +138,7 @@ describe('balena build', function() {
});
});
it('should create the expected tar stream (--emulated)', async () => {
itSS('should create the expected tar stream (--emulated)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const isV12W = isWindows && isV12();
const transposedDockerfile =
@ -373,9 +376,7 @@ describe('balena build: project validation', function() {
const { out, err } = await runCommand(
`build ${projectPath} -A amd64 -d nuc`,
);
expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedErrorLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
expect(out).to.be.empty;
});
});

View File

@ -173,6 +173,9 @@ describe('balena deploy', function() {
const expectedResponseLines = ['[Error] Deploy failed'];
const errMsg = 'Patch Image Error';
const expectedErrorLines = [errMsg];
// The SDK should produce an "unexpected" BalenaRequestError, which
// causes the CLI to call process.exit() with process.exitCode = 1
const expectedExitCode = 1;
// Mock this patch HTTP request to return status code 500, in which case
// the release status should be saved as "failed" rather than "success"
@ -202,17 +205,13 @@ describe('balena deploy', function() {
expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams },
expectedErrorLines,
expectedExitCode,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['main'],
});
// The SDK should produce an "unexpected" BalenaRequestError, which
// causes the CLI to call process.exit() with process.exitCode = 1
// @ts-ignore
sinon.assert.calledWith(process.exit);
expect(process.exitCode).to.equal(1);
});
});
@ -221,6 +220,7 @@ describe('balena deploy: project validation', function() {
this.beforeEach(() => {
api = new BalenaAPIMock();
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetMixpanel({ optional: true });
});
this.afterEach(() => {
@ -243,9 +243,7 @@ describe('balena deploy: project validation', function() {
const { out, err } = await runCommand(
`deploy testApp --source ${projectPath}`,
);
expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedErrorLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
expect(out).to.be.empty;
});
});

View File

@ -64,7 +64,7 @@ describe('balena devices', function() {
});
it('should list devices from own and collaborator apps', async () => {
api.expectGetWhoAmI({ optional: true });
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetMixpanel({ optional: true });
api.scope

View File

@ -486,9 +486,7 @@ describe('balena push: project validation', function() {
const { out, err } = await runCommand(
`push testApp --source ${projectPath} --nogitignore`,
);
expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedErrorLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
expect(out).to.be.empty;
});
@ -507,9 +505,7 @@ describe('balena push: project validation', function() {
const { out, err } = await runCommand(
`push testApp --source ${projectPath}`,
);
expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedErrorLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
expect(out).to.be.empty;
});
@ -536,12 +532,8 @@ describe('balena push: project validation', function() {
const { out, err } = await runCommand(
`push testApp --source ${projectPath} --nolive`,
);
expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedErrorLines);
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedOutputLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
expect(cleanOutput(out, true)).to.include.members(expectedOutputLines);
});
it('should suppress a parent folder check with --noparent-check', async () => {
@ -558,9 +550,7 @@ describe('balena push: project validation', function() {
const { out, err } = await runCommand(
`push testApp --source ${projectPath} --nolive --noparent-check`,
);
expect(
cleanOutput(err).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedErrorLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
expect(out).to.be.empty;
});
});

View File

@ -1,3 +1,19 @@
/**
* @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 fs from 'fs';
import { runCommand } from '../helpers';
@ -9,29 +25,35 @@ const nodeVersion = process.version.startsWith('v')
describe('balena version', function() {
it('should print the installed version of the CLI', async () => {
const { out } = await runCommand('version');
const { err, out } = await runCommand('version');
expect(err).to.be.empty;
expect(out.join('')).to.equal(`${packageJSON.version}\n`);
});
it('should print additional version information with the -a flag', async () => {
const { out } = await runCommand('version -a');
expect(out.join('')).to.equal(
`balena-cli version "${packageJSON.version}"
Node.js version "${nodeVersion}"
`,
const { err, out } = await runCommand('version -a');
expect(err).to.be.empty;
expect(out[0].trim()).to.equal(
`balena-cli version "${packageJSON.version}"`,
);
if (process.env.BALENA_CLI_TEST_TYPE === 'standalone') {
expect(out[1]).to.match(/Node.js version "\d+\.\d+.\d+"/);
} else {
expect(out[1].trim()).to.equal(`Node.js version "${nodeVersion}"`);
}
});
it('should print version information as JSON with the the -j flag', async () => {
const { out } = await runCommand('version -j');
const { err, out } = await runCommand('version -j');
expect(err).to.be.empty;
const json = JSON.parse(out.join(''));
expect(json['balena-cli']).to.equal(packageJSON.version);
expect(json).to.deep.equal({
'balena-cli': packageJSON.version,
'Node.js': nodeVersion,
});
if (process.env.BALENA_CLI_TEST_TYPE === 'standalone') {
expect(json['Node.js']).to.match(/\d+\.\d+.\d+/);
} else {
expect(json['Node.js']).to.equal(nodeVersion);
}
});
});

View File

@ -21,6 +21,7 @@ import * as _ from 'lodash';
import { fs } from 'mz';
import * as path from 'path';
import { PathUtils } from 'resin-multibuild';
import * as sinon from 'sinon';
import { Readable } from 'stream';
import * as tar from 'tar-stream';
import { streamToBuffer } from 'tar-utils';
@ -139,6 +140,7 @@ export async function testDockerBuildStream(o: {
expectedFilesByService: ExpectedTarStreamFilesByService;
expectedQueryParamsByService: { [service: string]: string[][] };
expectedErrorLines?: string[];
expectedExitCode?: number;
expectedResponseLines: string[];
projectPath: string;
responseCode: number;
@ -174,21 +176,25 @@ export async function testDockerBuildStream(o: {
o.dockerMock.expectGetImages();
}
const { out, err } = await runCommand(o.commandLine);
const cleanLines = (lines: string[]) =>
cleanOutput(lines).map(line => line.replace(/\s{2,}/g, ' '));
const { exitCode, out, err } = await runCommand(o.commandLine);
if (expectedErrorLines.length) {
expect(cleanLines(err)).to.include.members(expectedErrorLines);
expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
} else {
expect(err).to.be.empty;
}
if (expectedResponseLines.length) {
expect(cleanLines(out)).to.include.members(expectedResponseLines);
expect(cleanOutput(out, true)).to.include.members(expectedResponseLines);
} else {
expect(out).to.be.empty;
}
if (o.expectedExitCode != null) {
if (process.env.BALENA_CLI_TEST_TYPE !== 'standalone') {
// @ts-ignore
sinon.assert.calledWith(process.exit);
}
expect(o.expectedExitCode).to.equal(exitCode);
}
}
/**
@ -221,7 +227,5 @@ export async function testPushBuildStream(o: {
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);
expect(cleanOutput(out, true)).to.include.members(expectedResponseLines);
}

View File

@ -18,37 +18,63 @@
// tslint:disable-next-line:no-var-requires
require('./config-tests'); // required for side effects
import { execFile } from 'child_process';
import intercept = require('intercept-stdout');
import * as _ from 'lodash';
import { fs } from 'mz';
import * as nock from 'nock';
import * as path from 'path';
import * as balenaCLI from '../build/app';
import { setupSentry } from '../build/app-common';
export const runCommand = async (cmd: string) => {
const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena';
const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe);
interface TestOutput {
err: string[]; // stderr
out: string[]; // stdout
exitCode?: number; // process.exitCode
}
/**
* Filter stdout / stderr lines to remove lines that start with `[debug]` and
* other lines that can be ignored for testing purposes.
* @param testOutput
*/
function filterCliOutputForTests(testOutput: TestOutput): TestOutput {
return {
exitCode: testOutput.exitCode,
err: testOutput.err.filter(
(line: string) =>
!line.match(/\[debug\]/i) &&
// TODO stop this warning message from appearing when running
// sdk.setSharedOptions multiple times in the same process
!line.startsWith('Shared SDK options') &&
// Node 12: '[DEP0066] DeprecationWarning: OutgoingMessage.prototype._headers is deprecated'
!line.includes('[DEP0066]'),
),
out: testOutput.out.filter((line: string) => !line.match(/\[debug\]/i)),
};
}
/**
* Run the CLI in this same process, by calling the run() function in `app.ts`.
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
*/
async function runCommanInProcess(cmd: string): Promise<TestOutput> {
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];
const err: string[] = [];
const out: string[] = [];
const stdoutHook = (log: string | Buffer) => {
// Skip over debug messages
if (typeof log === 'string' && !log.startsWith('[debug]')) {
if (typeof log === 'string') {
out.push(log);
}
};
const stderrHook = (log: string | Buffer) => {
// Skip over debug messages
if (
typeof log === 'string' &&
!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') &&
// Node 12: '[DEP0066] DeprecationWarning: OutgoingMessage.prototype._headers is deprecated'
!log.includes('[DEP0066]')
) {
if (typeof log === 'string') {
err.push(log);
}
};
@ -58,14 +84,123 @@ export const runCommand = async (cmd: string) => {
await balenaCLI.run(preArgs.concat(cmd.split(' ')), {
noFlush: true,
});
return {
err,
out,
};
} finally {
unhookIntercept();
}
};
return filterCliOutputForTests({
err,
out,
// this makes sense if `process.exit()` was stubbed with sinon
exitCode: process.exitCode,
});
}
/**
* Run the command (e.g. `balena xxx args`) in a child process, instead of
* the same process as mocha. This is slow and does not allow mocking the
* source code, but it is useful for testing the standalone zip package binary.
* (Every now and then, bugs surface because of missing entries in the
* `pkg.assets` section of `package.json`, usually because of updated
* dependencies that don't clearly declare the have compatibility issues
* with `pkg`.)
*
* `mocha` runs on the parent process, and many of the tests inspect network
* traffic intercepted with `nock`. But this interception only works in the
* parent process itself. To get around this, we run a HTTP proxy server on
* the parent process, and get the child process to use it (the CLI already had
* support for proxy servers as a product feature, and this testing arrangement
* also exercises the proxy capabilities).
*
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
* @param proxyPort TCP port number for the HTTP proxy server running on the
* parent process
*/
async function runCommandInSubprocess(
cmd: string,
proxyPort: number,
): Promise<TestOutput> {
let exitCode = 0;
let stdout = '';
let stderr = '';
const addedEnvs = {
// Use http instead of https, so we can intercept and test the data,
// for example the contents of tar streams sent by the CLI to Docker
BALENARC_API_URL: 'http://api.balena-cloud.com',
BALENARC_BUILDER_URL: 'http://builder.balena-cloud.com',
BALENARC_PROXY: `http://127.0.0.1:${proxyPort}`,
// override default proxy exclusion to allow proxying of requests to 127.0.0.1
BALENARC_DO_PROXY: '127.0.0.1,localhost',
};
await new Promise(resolve => {
const child = execFile(
standalonePath,
cmd.split(' '),
{ env: { ...process.env, ...addedEnvs } },
($error, $stdout, $stderr) => {
stderr = $stderr || '';
stdout = $stdout || '';
// $error will be set if the CLI child process exits with a
// non-zero exit code. Usually this is harmless/expected, as
// the CLI child process is tested for error conditions.
if ($error && process.env.DEBUG) {
console.error(`
[debug] Error (possibly expected) executing child CLI process "${standalonePath}"
------------------------------------------------------------------
${$error}
------------------------------------------------------------------`);
}
resolve();
},
);
child.on('exit', (code: number, signal: string) => {
if (process.env.DEBUG) {
console.error(
`CLI child process exited with code=${code} signal=${signal}`,
);
}
exitCode = code;
});
});
const splitLines = (lines: string) =>
lines
.split(/[\r\n]/) // includes '\r' in isolation, used in progress bars
.filter(l => l)
.map(l => l + '\n');
return filterCliOutputForTests({
exitCode,
err: splitLines(stderr),
out: splitLines(stdout),
});
}
/**
* Run a CLI command and capture its stdout, stderr and exit code for testing.
* If the BALENA_CLI_TEST_TYPE env var is set to 'standalone', then the command
* will be executed in a separate child process, and a proxy server will be
* started in order to intercept and test HTTP requests.
* Otherwise, simply call the CLI's run() entry point in this same process.
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
*/
export async function runCommand(cmd: string): Promise<TestOutput> {
if (process.env.BALENA_CLI_TEST_TYPE === 'standalone') {
const semver = await import('semver');
if (semver.lt(process.version, '10.16.0')) {
throw new Error(
`The standalone tests require Node.js >= v10.16.0 because of net/proxy features ('global-agent' npm package)`,
);
}
if (!(await fs.exists(standalonePath))) {
throw new Error(`Standalone executable not found: "${standalonePath}"`);
}
const proxy = await import('./proxy-server');
const [proxyPort] = await proxy.createProxyServerOnce();
return runCommandInSubprocess(cmd, proxyPort);
} else {
return runCommanInProcess(cmd);
}
}
export const balenaAPIMock = () => {
if (!nock.isActive()) {
@ -85,13 +220,16 @@ export const balenaAPIMock = () => {
});
};
export function cleanOutput(output: string[] | string): string[] {
export function cleanOutput(
output: string[] | string,
collapseBlank = false,
): string[] {
const cleanLine = collapseBlank
? (line: string) => monochrome(line.trim()).replace(/\s{2,}/g, ' ')
: (line: string) => monochrome(line.trim());
return _(_.castArray(output))
.map((log: string) => {
return log.split('\n').map(line => {
return monochrome(line.trim());
});
})
.map((log: string) => log.split('\n').map(cleanLine))
.flatten()
.compact()
.value();
@ -106,7 +244,7 @@ export function cleanOutput(output: string[] | string): string[] {
* 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, '');
return text.replace(/\u001b\[\??(\d+;)*\d+[a-zA-Z]\r?/g, '');
}
/**

View File

@ -133,11 +133,22 @@ export class NockMock {
}
protected handleUnexpectedRequest(req: any) {
const {
interceptorServerPort,
} = require('./proxy-server') as typeof import('./proxy-server');
const o = req.options || {};
const u = o.uri || {};
const method = req.method;
const proto = req.protocol || req.proto || o.proto || u.protocol;
const host = req.host || req.headers?.host || o.host || u.host;
const path = req.path || o.path || u.path;
// Requests made by the local proxy/interceptor server are OK
if (host === `127.0.0.1:${interceptorServerPort}`) {
return;
}
console.error(
`Unexpected http request!: ${req.method} ${o.proto ||
u.protocol}//${o.host || u.host}${req.path || o.path || u.path}`,
`NockMock: Unexpected HTTP request: ${method} ${proto}//${host}${path}`,
);
// Errors thrown here are not causing the tests to fail for some reason.
// Possibly due to CLI global error handlers? (error.js)

217
tests/proxy-server.ts Normal file
View File

@ -0,0 +1,217 @@
/**
* @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.
*/
/**
* This module creates two HTTP servers listening on the local machine:
* * The "proxy server" which is a standard HTTP proxy server that handles the
* CONNECT HTTP verb, using the `http-proxy` dependency.
* * The "interceptor server" which actually handles the proxied requests.
*
* The proxy server proxies the client request to the interceptor server. (This
* two-server approach (proxy + interceptor) is mainly a result of accommodating
* the typical setup documented by the `http-proxy` dependency.)
*
* The use case for these servers is to test the standalone executable (CLI's
* standalone zip package) in a child process. Most of the CLI's automated tests
* currently test HTTP requests using `nock`, but `nock` can only mock/test the
* same process (Node's built-in `http` library). However, the CLI has support
* for proxy servers as a product feature, so the idea was to proxy the child
* process requests to the parent process, where the proxy / interceptor servers
* run. The interceptor server then forwards the request (mostly unchanged) with
* the expectation that `nock` will intercept the requests for testing (in the
* parent process) as usual.
*
* 1. A `mocha` test case calls `runCommand('push test-rpi')`, with `nock` setup
* to intercept HTTP requests (in the same process that runs `mocha`).
* 2. The proxy and interceptor servers are started in the parent process (only
* once: singleton) at free TCP port numbers randomly allocated by the OS.
* 3. A CLI child process gets spawned to run the command (`balena push test-rpi`)
* with environment variables including BALENARC_PROXY (set to
* 'http://127.0.0.1:${proxyPort}'). (Additional env vars instruct the
* child process to use HTTP instead of HTTPS for the balena API and builder.)
* 4. The child process sends the HTTP requests to the proxy server.
* 5. The proxy server forwards the request to the interceptor server.
* 6. The interceptor server simply re-issues the HTTP request (unchange), with
* the expectation that `nock` will intercept it.
* 7. `nock` (running on the parent process, same process that runs `mocha`)
* intercepts the HTTP request, test it and replies with a mocked response.
* 8. `nocks` response is returned to the interceptor server, which returns it
* to the proxy server, which returns it to the child process, which continues
* CLI command execution.
*/
import * as http from 'http';
const proxyServers: http.Server[] = [];
after(function() {
if (proxyServers.length) {
if (process.env.DEBUG) {
console.error(
`[debug] Closing proxy servers (count=${proxyServers.length})`,
);
}
proxyServers.forEach(s => s.close());
proxyServers.splice(0);
}
});
export let proxyServerPort = 0;
export let interceptorServerPort = 0;
export async function createProxyServerOnce(): Promise<[number, number]> {
if (proxyServerPort === 0) {
[proxyServerPort, interceptorServerPort] = await createProxyServer();
}
return [proxyServerPort, interceptorServerPort];
}
async function createProxyServer(): Promise<[number, number]> {
const httpProxy = require('http-proxy') as typeof import('http-proxy');
const interceptorPort = await createInterceptorServer();
const proxy = httpProxy.createProxyServer();
proxy.on('error', function(
err: Error,
_req: http.IncomingMessage,
res: http.ServerResponse,
) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
const msg = `Proxy server error: ${err}`;
console.error(msg);
res.end(msg);
});
const server = http.createServer(function(
req: http.IncomingMessage,
res: http.ServerResponse,
) {
if (process.env.DEBUG) {
console.error(`[debug] Proxy forwarding for ${req.url}`);
}
proxy.web(req, res, { target: `http://127.0.0.1:${interceptorPort}` });
});
proxyServers.push(server);
server.on('error', (err: Error) => {
console.error(`Proxy server error (http.createServer):\n${err}`);
});
let proxyPort = 0; // TCP port number, 0 means automatic allocation
await new Promise((resolve, reject) => {
const listener = server.listen(0, '127.0.0.1', (err: Error) => {
if (err) {
console.error(`Error starting proxy server:\n${err}`);
reject(err);
} else {
const info: any = listener.address();
proxyPort = info.port;
console.error(
`[Info] Proxy server listening on ${info.address}:${proxyPort}`,
);
resolve();
}
});
});
return [proxyPort, interceptorPort];
}
async function createInterceptorServer(): Promise<number> {
const url = await import('url');
const server = http.createServer();
proxyServers.push(server);
server
.on('error', (err: Error) => {
console.error(`Interceptor server error: ${err}`);
})
.on(
'request',
(cliReq: http.IncomingMessage, cliRes: http.ServerResponse) => {
const proxiedFor = `http://${cliReq.headers.host}${cliReq.url}`;
if (process.env.DEBUG) {
console.error(`[debug] Interceptor forwarding for ${proxiedFor}`);
}
// tslint:disable-next-line:prefer-const
let { protocol, hostname, port, path: urlPath, hash } = url.parse(
proxiedFor,
);
protocol = (protocol || 'http:').toLowerCase();
port = port || (protocol === 'https:' ? '443' : '80');
const reqOpts = {
protocol,
port,
host: hostname,
path: `${urlPath || ''}${hash || ''}`,
method: cliReq.method,
headers: cliReq.headers,
};
const srvReq = http.request(reqOpts);
srvReq
.on('error', err => {
console.error(
`Interceptor server error in onward request:\n${err}`,
);
})
.on('response', (srvRes: http.IncomingMessage) => {
// Copy headers, status code and status message from interceptor to client
for (const [key, val] of Object.entries(srvRes.headers)) {
if (key && val) {
cliRes.setHeader(key, val);
}
}
cliRes.statusCode = srvRes.statusCode || cliRes.statusCode;
cliRes.statusMessage = srvRes.statusMessage || cliRes.statusMessage;
srvRes.pipe(cliRes).on('error', (err: Error) => {
console.error(
`Interceptor server error piping response to proxy server:\n${err}`,
);
cliRes.end();
});
});
cliReq.pipe(srvReq).on('error', (err: Error) => {
console.error(
`Proxy server error piping client request onward:\n${err}`,
);
});
},
);
let interceptorPort = 0;
await new Promise((resolve, reject) => {
const listener = server.listen(0, '127.0.0.1', (err: Error) => {
if (err) {
console.error(`Error starting interceptor server:\n${err}`);
reject(err);
} else {
const info: any = listener.address();
interceptorPort = info.port;
console.error(
`[Info] Interceptor server listening on ${info.address}:${interceptorPort}`,
);
resolve();
}
});
});
return interceptorPort;
}

56
tests/supervisor-mock.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* @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 { Readable } from 'stream';
import { NockMock, ScopeOpts } from './nock-mock';
export const dockerResponsePath = path.normalize(
path.join(__dirname, 'test-data', 'docker-response'),
);
export class SupervisorMock extends NockMock {
constructor() {
super(/1\.2\.3\.4:48484/);
}
public expectGetPing(opts: ScopeOpts = {}) {
this.optGet('/ping', opts).reply(200, 'OK');
}
public expectGetLogs(opts: ScopeOpts = {}) {
const chunks = [
'\n',
'{"message":"Streaming logs","isSystem":true}\n',
'{"serviceName":"bar","serviceId":1,"imageId":1,"isStdout":true,"timestamp":1591991625223,"message":"bar 8 (332) Linux 4e3f81149d71 4.19.75 #1 SMP PREEMPT Mon Mar 23 11:50:49 UTC 2020 aarch64 GNU/Linux"}\n',
'{"serviceName":"foo","serviceId":2,"imageId":2,"isStdout":true,"timestamp":1591991628757,"message":"foo 8 (200) Linux cc5df60d89ee 4.19.75 #1 SMP PREEMPT Mon Mar 23 11:50:49 UTC 2020 aarch64 GNU/Linux"}\n',
];
let chunkCount = 0;
const chunkedStream = new Readable({
read(_size) {
setTimeout(() => {
this.push(chunkCount === chunks.length ? null : chunks[chunkCount++]);
}, 10);
},
});
this.optGet('/v2/local/logs', opts).reply((_uri, _reqBody, cb) => {
cb(null, [200, chunkedStream]);
});
}
}