balena-cli/tests/helpers.ts
2020-06-24 18:13:48 +01:00

300 lines
9.5 KiB
TypeScript

/**
* @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.
*/
// 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 { promises as fs } from 'fs';
import * as nock from 'nock';
import * as path from 'path';
import * as balenaCLI from '../build/app';
import { setupSentry } from '../build/app-common';
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) => {
if (typeof log === 'string') {
out.push(log);
}
};
const stderrHook = (log: string | Buffer) => {
if (typeof log === 'string') {
err.push(log);
}
};
const unhookIntercept = intercept(stdoutHook, stderrHook);
try {
await balenaCLI.run(preArgs.concat(cmd.split(' ')), {
noFlush: true,
});
} 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)`,
);
}
try {
await fs.access(standalonePath);
} catch {
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()) {
nock.activate();
}
return nock(/./).get('/config/vars').reply(200, {
reservedNames: [],
reservedNamespaces: [],
invalidRegex: '/^d|W/',
whiteListedNames: [],
whiteListedNamespaces: [],
blackListedNames: [],
configVarSchema: [],
});
};
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) => log.split('\n').map(cleanLine))
.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+;)*\d+[a-zA-Z]\r?/g, '');
}
/**
* 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;
}
export function fillTemplateArray(
templateStringArray: string[],
templateVars: object,
): string[];
export function fillTemplateArray(
templateStringArray: Array<string | string[]>,
templateVars: object,
): Array<string | string[]>;
export function fillTemplateArray(
templateStringArray: Array<string | string[]>,
templateVars: object,
): Array<string | string[]> {
return templateStringArray.map((i) =>
Array.isArray(i)
? fillTemplateArray(i, templateVars)
: fillTemplate(i, templateVars),
);
}
export async function switchSentry(
enabled: boolean | undefined,
): Promise<boolean | undefined> {
const sentryOpts = (await setupSentry()).getClient()?.getOptions();
if (sentryOpts) {
const sentryStatus = sentryOpts.enabled;
sentryOpts.enabled = enabled;
return sentryStatus;
}
}