cli: move out e2e-test to separate package

This commit is contained in:
Patrik Oldsberg
2020-08-23 11:25:10 +02:00
parent d7a10bcf12
commit 1358739063
7 changed files with 67 additions and 6 deletions
-29
View File
@@ -1,29 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
module.exports = {
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
optionalDependencies: true,
peerDependencies: true,
bundledDependencies: true,
},
],
},
};
-387
View File
@@ -1,387 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const os = require('os');
const fs = require('fs-extra');
const fetch = require('node-fetch');
const handlebars = require('handlebars');
const killTree = require('tree-kill');
const { resolve: resolvePath, join: joinPath } = require('path');
const Browser = require('zombie');
const {
spawnPiped,
runPlain,
handleError,
waitForPageWithText,
waitFor,
waitForExit,
print,
} = require('./helpers');
const pgtools = require('pgtools');
async function main() {
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
print(`CLI E2E test root: ${rootDir}\n`);
print('Building dist workspace');
const workspaceDir = await buildDistWorkspace('workspace', rootDir);
const isPostgres = Boolean(process.env.POSTGRES_USER);
print('Creating a Backstage App');
const appDir = await createApp('test-app', isPostgres, workspaceDir, rootDir);
print('Creating a Backstage Plugin');
const pluginName = await createPlugin('test-plugin', appDir);
print('Starting the app');
await testAppServe(pluginName, appDir);
print('Testing the backend startup');
await testBackendStart(appDir, isPostgres);
print('All tests successful, removing test dir');
await fs.remove(rootDir);
}
/**
* Builds a dist workspace that contains the cli and core packages
*/
async function buildDistWorkspace(workspaceName, rootDir) {
const workspaceDir = resolvePath(rootDir, workspaceName);
await fs.ensureDir(workspaceDir);
// We grab the needed dependencies from the template packages
const appPkgTemplate = await fs.readFile(
resolvePath(
__dirname,
'../../create-app/templates/default-app/packages/app/package.json.hbs',
),
'utf8',
);
const appPkg = JSON.parse(
handlebars.compile(appPkgTemplate)({ version: '0.0.0' }),
);
const appDeps = Object.keys(appPkg.dependencies).filter(name =>
name.startsWith('@backstage/'),
);
const backendPkgTemplate = await fs.readFile(
resolvePath(
__dirname,
'../../create-app/templates/default-app/packages/backend/package.json.hbs',
),
'utf8',
);
const backendPkg = JSON.parse(
handlebars.compile(backendPkgTemplate)({ version: '0.0.0' }),
);
const backendDeps = Object.keys(backendPkg.dependencies).filter(name =>
name.startsWith('@backstage/'),
);
print(`Preparing workspace`);
await runPlain([
'yarn',
'backstage-cli',
'build-workspace',
workspaceDir,
'@backstage/cli',
'@backstage/create-app',
'@backstage/core',
'@backstage/dev-utils',
'@backstage/test-utils',
...appDeps,
...backendDeps,
]);
print('Pinning yarn version in workspace');
await pinYarnVersion(workspaceDir);
print('Installing workspace dependencies');
await runPlain(['yarn', 'install', '--production', '--frozen-lockfile'], {
cwd: workspaceDir,
});
return workspaceDir;
}
/**
* Pin the yarn version in a directory to the one we're using in the Backstage repo
*/
async function pinYarnVersion(dir) {
const repoRoot = resolvePath(__dirname, '../../..');
const yarnRc = await fs.readFile(resolvePath(repoRoot, '.yarnrc'), 'utf8');
const yarnRcLines = yarnRc.split('\n');
const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarn-path'));
const [, localYarnPath] = yarnPathLine.match(/"(.*)"/);
const yarnPath = resolvePath(repoRoot, localYarnPath);
await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`);
}
/**
* Creates a new app inside rootDir called test-app, using packages from the workspaceDir
*/
async function createApp(appName, isPostgres, workspaceDir, rootDir) {
const child = spawnPiped(
[
'node',
resolvePath(workspaceDir, 'packages/create-app/bin/backstage-create-app'),
'--skip-install',
],
{
cwd: rootDir,
},
);
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
child.stdin.write(`${appName}\n`);
await waitFor(() => stdout.includes('Select database for the backend'));
if (!isPostgres) {
// Simulate down arrow press
child.stdin.write(`\u001B\u005B\u0042`);
}
child.stdin.write(`\n`);
print('Waiting for app create script to be done');
await waitForExit(child);
const appDir = resolvePath(rootDir, appName);
print('Rewriting module resolutions of app to use workspace packages');
await overrideModuleResolutions(appDir, workspaceDir);
print('Pinning yarn version and registry in app');
await pinYarnVersion(appDir);
await fs.writeFile(
resolvePath(appDir, '.npmrc'),
'registry=https://registry.npmjs.org/\n',
);
print('Test app created');
for (const cmd of ['install', 'tsc', 'build', 'lint:all', 'test:all']) {
print(`Running 'yarn ${cmd}' in newly created app`);
await runPlain(['yarn', cmd], { cwd: appDir });
}
print(`Running 'yarn test:e2e:ci' in newly created app`);
await runPlain(['yarn', 'test:e2e:ci'], {
cwd: resolvePath(appDir, 'packages', 'app'),
env: {
...process.env,
APP_CONFIG_app_baseUrl: '"http://localhost:3001"',
},
});
return appDir;
} finally {
child.kill();
}
}
/**
* This points dependency resolutions into the workspace for each package that is present there
*/
async function overrideModuleResolutions(appDir, workspaceDir) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
for (const dir of ['packages', 'plugins']) {
const packageNames = await fs.readdir(resolvePath(workspaceDir, dir));
for (const pkgDir of packageNames) {
const pkgPath = joinPath('..', 'workspace', dir, pkgDir);
const { name } = await fs.readJson(
resolvePath(workspaceDir, dir, pkgDir, 'package.json'),
);
pkgJson.dependencies[name] = `file:${pkgPath}`;
pkgJson.resolutions[name] = `file:${pkgPath}`;
delete pkgJson.devDependencies[name];
}
}
fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
/**
* Uses create-plugin command to create a new plugin in the app
*/
async function createPlugin(pluginName, appDir) {
const child = spawnPiped(['yarn', 'create-plugin'], {
cwd: appDir,
});
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
child.stdin.write(`${pluginName}\n`);
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// child.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(child);
const pluginDir = resolvePath(appDir, 'plugins', pluginName);
for (const cmd of [['tsc'], ['lint'], ['test', '--no-watch']]) {
print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`);
await runPlain(['yarn', ...cmd], { cwd: pluginDir });
}
return pluginName;
} finally {
child.kill();
}
}
/**
* Start serving the newly created app and make sure that the create plugin is rendering correctly
*/
async function testAppServe(pluginName, appDir) {
const startApp = spawnPiped(['yarn', 'start'], {
cwd: appDir,
});
Browser.localhost('localhost', 3000);
let successful = false;
try {
const browser = new Browser();
await waitForPageWithText(browser, '/', 'Backstage Service Catalog');
await waitForPageWithText(
browser,
`/${pluginName}`,
`Welcome to ${pluginName}!`,
);
print('Both App and Plugin loaded correctly');
successful = true;
} catch (error) {
throw new Error(`App serve test failed, ${error}`);
} finally {
// Kill entire process group, otherwise we'll end up with hanging serve processes
killTree(startApp.pid);
}
try {
await waitForExit(startApp);
} catch (error) {
if (!successful) {
throw error;
}
}
}
/** Creates PG databases (drops if exists before) */
async function createDB(database) {
const config = {
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
};
try {
await pgtools.dropdb({ config }, database);
} catch (_) {
/* do nothing*/
}
return pgtools.createdb(config, database);
}
/**
* Start serving the newly created backend and make sure that all db migrations works correctly
*/
async function testBackendStart(appDir, isPostgres) {
if (isPostgres) {
print('Creating DBs');
await Promise.all(
[
'catalog',
'scaffolder',
'auth',
'identity',
'proxy',
'techdocs',
].map(name => createDB(`backstage_plugin_${name}`)),
);
print('Created DBs');
}
const child = spawnPiped(['yarn', 'workspace', 'backend', 'start'], {
cwd: appDir,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
child.stderr.on('data', data => {
stderr = stderr + data.toString('utf8');
});
let successful = false;
try {
await waitFor(() => stdout.includes('Listening on ') || stderr !== '');
if (stderr !== '') {
// Skipping the whole block
throw new Error(stderr);
}
print('Try to fetch entities from the backend');
// Try fetch entities, should be ok
await fetch('http://localhost:7000/catalog/entities').then(res =>
res.json(),
);
print('Entities fetched successfully');
successful = true;
} catch (error) {
throw new Error(`Backend failed to startup: ${error}`);
} finally {
print('Stopping the child process');
// Kill entire process group, otherwise we'll end up with hanging serve processes
killTree(child.pid);
}
try {
await waitForExit(child);
} catch (error) {
if (!successful) {
throw new Error(`Backend failed to startup: ${stderr}`);
}
print('Backend startup test finished successfully');
}
}
process.on('unhandledRejection', handleError);
main(process.argv.slice(2)).catch(handleError);
-178
View File
@@ -1,178 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const { spawn, execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const execFile = promisify(execFileCb);
const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/;
function spawnPiped(cmd, options) {
function pipeWithPrefix(stream, prefix = '') {
return data => {
const prefixedMsg = data
.toString('utf8')
.trimRight()
.replace(/^/gm, prefix);
stream.write(`${prefixedMsg}\n`, 'utf8');
};
}
const child = spawn(cmd[0], cmd.slice(1), {
stdio: 'pipe',
shell: true,
...options,
});
child.on('error', handleError);
const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' ');
child.stdout.on(
'data',
pipeWithPrefix(process.stdout, `[${logPrefix}].out: `),
);
child.stderr.on(
'data',
pipeWithPrefix(process.stderr, `[${logPrefix}].err: `),
);
return child;
}
async function runPlain(cmd, options) {
try {
const { stdout } = await execFile(cmd[0], cmd.slice(1), {
...options,
shell: true,
});
return stdout.trim();
} catch (error) {
if (error.stdout) {
process.stdout.write(error.stdout);
}
if (error.stderr) {
process.stderr.write(error.stderr);
}
throw error;
}
}
function handleError(err) {
process.stdout.write(`${err.name}: ${err.stack || err.message}\n`);
if (typeof err.code === 'number') {
process.exit(err.code);
} else {
process.exit(1);
}
}
/**
* Waits for fn() to be true
* Checks every 100ms
* .cancel() is available
* @returns {Promise} Promise of resolution
*/
function waitFor(fn) {
return new Promise(resolve => {
const handle = setInterval(() => {
if (fn()) {
clearInterval(handle);
resolve();
return;
}
}, 100);
});
}
async function waitForExit(child) {
if (child.exitCode !== null) {
throw new Error(`Child already exited with code ${child.exitCode}`);
}
await new Promise((resolve, reject) =>
child.once('exit', code => {
if (code) {
reject(new Error(`Child exited with code ${code}`));
} else {
print('Child finished');
resolve();
}
}),
);
}
async function waitForPageWithText(
browser,
path,
text,
{ intervalMs = 1000, maxLoadAttempts = 240, maxFindTextAttempts = 3 } = {},
) {
let loadAttempts = 0;
for (;;) {
try {
await new Promise(resolve => setTimeout(resolve, intervalMs));
await browser.visit(path);
break;
} catch (error) {
if (error.message.match(EXPECTED_LOAD_ERRORS)) {
loadAttempts++;
if (loadAttempts >= maxLoadAttempts) {
throw new Error(
`Failed to load page '${path}', max number of attempts reached`,
);
}
} else {
throw error;
}
}
}
// The page may not be fully loaded and hence we need to retry.
let findTextAttempts = 0;
const escapedText = text.replace(/"/g, '\\"');
for (;;) {
try {
browser.assert.evaluate(
`Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`,
true,
`expected to find text ${text}`,
);
break;
} catch (error) {
findTextAttempts++;
if (findTextAttempts <= maxFindTextAttempts) {
await browser.visit(path);
await new Promise(resolve => setTimeout(resolve, intervalMs));
continue;
} else {
throw error;
}
}
}
}
function print(msg) {
return process.stdout.write(`${msg}\n`);
}
module.exports = {
spawnPiped,
runPlain,
handleError,
waitFor,
waitForExit,
waitForPageWithText,
print,
};
+1 -6
View File
@@ -21,7 +21,6 @@
"build": "backstage-cli build --outputs cjs",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"test:e2e": "node e2e-test/cli-e2e-test.js",
"clean": "backstage-cli clean",
"start": "nodemon --"
},
@@ -69,9 +68,7 @@
"jest-css-modules": "^2.1.0",
"jest-esm-transformer": "^1.0.0",
"mini-css-extract-plugin": "^0.9.0",
"node-fetch": "^2.6.0",
"ora": "^4.0.3",
"pgtools": "^0.3.0",
"raw-loader": "^4.0.1",
"react": "^16.0.0",
"react-dev-utils": "^10.2.1",
@@ -118,9 +115,7 @@
"@types/webpack-dev-server": "^3.10.0",
"del": "^5.1.0",
"nodemon": "^2.0.2",
"tree-kill": "^1.2.2",
"ts-node": "^8.6.2",
"zombie": "^6.1.4"
"ts-node": "^8.6.2"
},
"files": [
"asset-types",