/** * @license * Copyright 2019-2021 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 * as packageJSON from '../package.json'; const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena'; const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe); export interface TestOutput { err: string[]; // stderr out: string[]; // stdout exitCode?: number; // process.exitCode } function matchesNodeEngineVersionWarn(msg: string) { if (/^-----+\r?\n?$/.test(msg)) { return true; } const cleanup = (line: string): string[] => line .replace(/-----+/g, '') .replace(/"\d+\.\d+\.\d+"/, '"x.y.z"') .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l); const { getNodeEngineVersionWarn } = require('../build/utils/messages'); let nodeEngineWarn: string = getNodeEngineVersionWarn( 'x.y.z', packageJSON.engines.node, ); const nodeEngineWarnArray = cleanup(nodeEngineWarn); nodeEngineWarn = nodeEngineWarnArray.join('\n'); msg = cleanup(msg).join('\n'); return msg === nodeEngineWarn || nodeEngineWarnArray.includes(msg); } /** * Filter stdout / stderr lines to remove lines that start with `[debug]` and * other lines that can be ignored for testing purposes. * @param testOutput */ export function filterCliOutputForTests({ err, out, }: { err: string[]; out: string[]; }): { err: string[]; out: string[] } { return { err: err.filter( (line: string) => line && !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') && !line.startsWith('WARN: disabling Sentry.io error reporting') && !matchesNodeEngineVersionWarn(line), ), out: out.filter((line: string) => line && !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 runCommandInProcess(cmd: string): Promise { const balenaCLI = await import('../build/app'); const intercept = await import('intercept-stdout'); 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(' ').filter((c) => c)), { dir: path.resolve(__dirname, '..'), noFlush: true, }); } finally { unhookIntercept(); } const filtered = filterCliOutputForTests({ err, out }); return { err: filtered.err, out: filtered.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 { 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', }; const { execFile } = await import('child_process'); await new Promise((resolve) => { const child = execFile( standalonePath, cmd.split(' ').filter((c) => c), { 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) { const msg = ` Error (possibly expected) executing child CLI process "${standalonePath}" ${$error}`; const { warnify } = require('../build/utils/messages') as typeof import('../build/utils/messages'); console.error(warnify(msg, '[debug] ')); } 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'); const filtered = filterCliOutputForTests({ err: splitLines(stderr), out: splitLines(stdout), }); return { err: filtered.err, out: filtered.out, // this makes sense if `process.exit()` was stubbed with sinon exitCode, }; } /** * 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 { 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 { const { promises: fs } = await import('fs'); await fs.access(standalonePath); } catch { throw new Error(`Standalone executable not found: "${standalonePath}"`); } const proxy = await import('./nock/proxy-server'); const [proxyPort] = await proxy.createProxyServerOnce(); return runCommandInSubprocess(cmd, proxyPort); } else { return runCommandInProcess(cmd); } } 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()); const result: string[] = []; output = typeof output === 'string' ? [output] : output; for (const lines of output) { for (let line of lines.split('\n')) { line = cleanLine(line); if (line) { result.push(line); } } } return result; } /** * 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 { // eslint-disable-next-line no-control-regex 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; } /** * Recursively navigate the `data` argument (if it is an array or object), * finding and replacing "template strings" such as 'hello ${name}!' with * the variable values given in `templateVars` such as { name: 'world' }. * * @param data Any data type (array, object, string) containing template * strings to be replaced * @param templateVars Map of template variable names to values */ export function deepTemplateReplace( data: any, templateVars: { [key: string]: any }, ): any { switch (typeof data) { case 'string': return fillTemplate(data, templateVars); case 'object': if (Array.isArray(data)) { return data.map((i) => deepTemplateReplace(i, templateVars)); } return _.mapValues(data, (value) => deepTemplateReplace(value, templateVars), ); default: // number, undefined, null, or something else return data; } } export const fillTemplateArray = deepTemplateReplace; /** * Recursively navigate the `data` argument (if it is an array or object), * looking for strings that start with `[` or `{` which are assumed to contain * JSON arrays or objects that are then parsed with JSON.parse(). * @param data */ export function deepJsonParse(data: any): any { if (typeof data === 'string') { const maybeJson = data.trim(); if (maybeJson.startsWith('{') || maybeJson.startsWith('[')) { return JSON.parse(maybeJson); } } else if (Array.isArray(data)) { return data.map((i) => deepJsonParse(i)); } else if (typeof data === 'object') { return _.mapValues(data, (value) => deepJsonParse(value)); } return data; } export async function switchSentry( enabled: boolean | undefined, ): Promise { const balenaCLI = await import('../build/app'); const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions(); if (sentryOpts) { const sentryStatus = sentryOpts.enabled; sentryOpts.enabled = enabled; return sentryStatus; } }