Merge pull request #2085 from spotify/rugvip/se2ep

packages: move CLI e2e test to separate package, port to TS and slim down workflow
This commit is contained in:
Patrik Oldsberg
2020-08-23 22:40:58 +02:00
committed by GitHub
11 changed files with 243 additions and 123 deletions
@@ -1,12 +1,13 @@
name: CLI Test Windows
name: E2E Test Windows
# Building on windows is really slow, so this workflow is separate from cli.yml and only builds on changes
# Building on windows is really slow, so this workflow is separate from e2e.yml and only builds on changes
# to the cli itself. They're more likely to introduce issues on windows, compared to changes to core and yarn.lock.
on:
pull_request:
paths:
- '.github/workflows/cli-win.yml'
- '.github/workflows/e2e-win.yml'
- 'packages/cli/**'
- 'packages/e2e/**'
- 'packages/create-app/**'
jobs:
@@ -42,6 +43,7 @@ jobs:
- name: yarn install
run: yarn install --frozen-lockfile
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
- name: yarn build
run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli --ignore backstage-microsite
- name: run E2E test
run: yarn workspace e2e-test start
@@ -1,11 +1,10 @@
name: CLI Test
name: E2E Test Linux
on:
pull_request:
paths-ignore:
- 'microsite/**'
jobs:
build:
runs-on: ${{ matrix.os }}
@@ -51,13 +50,14 @@ jobs:
- name: yarn install
run: yarn install --frozen-lockfile
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
- name: yarn build
run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli --ignore backstage-microsite
- name: run E2E test
run: |
sudo sysctl fs.inotify.max_user_watches=524288
yarn workspace e2e-test start
env:
POSTGRES_HOST: localhost
POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
run: |
sudo sysctl fs.inotify.max_user_watches=524288
node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
+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",
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
ignorePatterns: ['templates/**'],
rules: {
'no-console': 0,
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
optionalDependencies: false,
peerDependencies: false,
bundledDependencies: false,
},
],
},
};
+24
View File
@@ -0,0 +1,24 @@
# e2e-test
End-to-end test for verifying Backstage packages.
## Usage
This package is only meant for usage within the Backstage monorepo.
All packages need to be installed and built before running the test. In a fresh clone of this repo you first need to run the following from the repo root:
```sh
yarn install
yarn tsc
yarn build
```
Once those tasks have completed, you can now run the test using `yarn start` inside this package.
If you make changes to other packages you will need to rerun `yarn tsc && yarn build`. Changes to this package do not require a rebuild.
## Documentation
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+35
View File
@@ -0,0 +1,35 @@
{
"name": "e2e-test",
"description": "E2E test for verifying Backstage packages",
"version": "0.0.0",
"private": true,
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/spotify/backstage",
"directory": "packages/e2e-test"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "src/index.js",
"scripts": {
"start": "node .",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"test:e2e": "yarn start"
},
"devDependencies": {
"@backstage/cli-common": "^0.1.1-alpha.19",
"@types/fs-extra": "^9.0.1",
"@types/node": "^13.7.2",
"fs-extra": "^9.0.0",
"handlebars": "^4.7.3",
"node-fetch": "^2.6.0",
"pgtools": "^0.3.0",
"tree-kill": "^1.2.2",
"ts-node": "^8.6.2",
"zombie": "^6.1.4"
}
}
@@ -14,14 +14,14 @@
* 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 {
import os from 'os';
import fs from 'fs-extra';
import fetch from 'node-fetch';
import handlebars from 'handlebars';
import killTree from 'tree-kill';
import { resolve as resolvePath, join as joinPath } from 'path';
import Browser from 'zombie';
import {
spawnPiped,
runPlain,
handleError,
@@ -29,8 +29,17 @@ const {
waitFor,
waitForExit,
print,
} = require('./helpers');
const pgtools = require('pgtools');
} from './helpers';
import pgtools from 'pgtools';
import { findPaths } from '@backstage/cli-common';
const paths = findPaths(__dirname);
const templatePackagePaths = [
'packages/cli/templates/default-plugin/package.json.hbs',
'packages/create-app/templates/default-app/packages/app/package.json.hbs',
'packages/create-app/templates/default-app/packages/backend/package.json.hbs',
];
async function main() {
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
@@ -59,38 +68,24 @@ async function main() {
/**
* Builds a dist workspace that contains the cli and core packages
*/
async function buildDistWorkspace(workspaceName, rootDir) {
async function buildDistWorkspace(workspaceName: string, rootDir: string) {
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/'),
);
// We grab the needed dependencies from the create app template
const createAppDeps = new Set<string>();
for (const pkgJsonPath of templatePackagePaths) {
const path = paths.resolveOwnRoot(pkgJsonPath);
const pkgTemplate = await fs.readFile(path, 'utf8');
const { dependencies = {}, devDependencies = {} } = JSON.parse(
handlebars.compile(pkgTemplate)({ version: '0.0.0' }),
);
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/'),
);
Array<string>()
.concat(Object.keys(dependencies), Object.keys(devDependencies))
.filter(name => name.startsWith('@backstage/'))
.forEach(dep => createAppDeps.add(dep));
}
print(`Preparing workspace`);
await runPlain([
@@ -98,13 +93,8 @@ async function buildDistWorkspace(workspaceName, rootDir) {
'backstage-cli',
'build-workspace',
workspaceDir,
'@backstage/cli',
'@backstage/create-app',
'@backstage/core',
'@backstage/dev-utils',
'@backstage/test-utils',
...appDeps,
...backendDeps,
...createAppDeps,
]);
print('Pinning yarn version in workspace');
@@ -121,14 +111,19 @@ async function buildDistWorkspace(workspaceName, rootDir) {
/**
* 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');
async function pinYarnVersion(dir: string) {
const yarnRc = await fs.readFile(paths.resolveOwnRoot('.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);
if (!yarnPathLine) {
throw new Error(`Unable to find 'yarn-path' in ${yarnRc}`);
}
const match = yarnPathLine.match(/"(.*)"/);
if (!match) {
throw new Error(`Invalid 'yarn-path' in ${yarnRc}`);
}
const [, localYarnPath] = match;
const yarnPath = paths.resolveOwnRoot(localYarnPath);
await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`);
}
@@ -136,7 +131,12 @@ async function pinYarnVersion(dir) {
/**
* Creates a new app inside rootDir called test-app, using packages from the workspaceDir
*/
async function createApp(appName, isPostgres, workspaceDir, rootDir) {
async function createApp(
appName: string,
isPostgres: boolean,
workspaceDir: string,
rootDir: string,
) {
const child = spawnPiped(
[
'node',
@@ -150,20 +150,20 @@ async function createApp(appName, isPostgres, workspaceDir, rootDir) {
try {
let stdout = '';
child.stdout.on('data', data => {
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`);
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(`\u001B\u005B\u0042`);
}
child.stdin.write(`\n`);
child.stdin?.write(`\n`);
print('Waiting for app create script to be done');
await waitForExit(child);
@@ -205,7 +205,7 @@ async function createApp(appName, isPostgres, workspaceDir, rootDir) {
/**
* This points dependency resolutions into the workspace for each package that is present there
*/
async function overrideModuleResolutions(appDir, workspaceDir) {
async function overrideModuleResolutions(appDir: string, workspaceDir: string) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
@@ -231,19 +231,19 @@ async function overrideModuleResolutions(appDir, workspaceDir) {
/**
* Uses create-plugin command to create a new plugin in the app
*/
async function createPlugin(pluginName, appDir) {
async function createPlugin(pluginName: string, appDir: string) {
const child = spawnPiped(['yarn', 'create-plugin'], {
cwd: appDir,
});
try {
let stdout = '';
child.stdout.on('data', data => {
child.stdout?.on('data', (data: Buffer) => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
child.stdin.write(`${pluginName}\n`);
child.stdin?.write(`${pluginName}\n`);
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// child.stdin.write('@someuser\n');
@@ -266,7 +266,7 @@ async function createPlugin(pluginName, appDir) {
/**
* Start serving the newly created app and make sure that the create plugin is rendering correctly
*/
async function testAppServe(pluginName, appDir) {
async function testAppServe(pluginName: string, appDir: string) {
const startApp = spawnPiped(['yarn', 'start'], {
cwd: appDir,
});
@@ -302,7 +302,7 @@ async function testAppServe(pluginName, appDir) {
}
/** Creates PG databases (drops if exists before) */
async function createDB(database) {
async function createDB(database: string) {
const config = {
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT,
@@ -321,7 +321,7 @@ async function createDB(database) {
/**
* Start serving the newly created backend and make sure that all db migrations works correctly
*/
async function testBackendStart(appDir, isPostgres) {
async function testBackendStart(appDir: string, isPostgres: boolean) {
if (isPostgres) {
print('Creating DBs');
await Promise.all(
@@ -343,10 +343,10 @@ async function testBackendStart(appDir, isPostgres) {
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
child.stdout?.on('data', (data: Buffer) => {
stdout = stdout + data.toString('utf8');
});
child.stderr.on('data', data => {
child.stderr?.on('data', (data: Buffer) => {
stderr = stderr + data.toString('utf8');
});
let successful = false;
@@ -384,4 +384,4 @@ async function testBackendStart(appDir, isPostgres) {
}
process.on('unhandledRejection', handleError);
main(process.argv.slice(2)).catch(handleError);
main().catch(handleError);
+33
View File
@@ -0,0 +1,33 @@
/*
* 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.
*/
import { waitFor } from './helpers';
describe('waitFor', () => {
it('should wait for true', async () => {
const fn = jest.fn().mockReturnValue(true);
await waitFor(fn);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should time out', async () => {
const fn = jest.fn().mockReturnValue(false);
await expect(waitFor(fn, 1)).rejects.toThrow(
'Timed out while waiting for condition',
);
expect(fn).toHaveBeenCalled();
});
});
@@ -14,16 +14,21 @@
* limitations under the License.
*/
const { spawn, execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
import {
spawn,
execFile as execFileCb,
SpawnOptions,
ChildProcess,
} from 'child_process';
import { promisify } from '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 => {
export function spawnPiped(cmd: string[], options?: SpawnOptions) {
function pipeWithPrefix(stream: NodeJS.WriteStream, prefix = '') {
return (data: Buffer) => {
const prefixedMsg = data
.toString('utf8')
.trimRight()
@@ -40,11 +45,11 @@ function spawnPiped(cmd, options) {
child.on('error', handleError);
const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' ');
child.stdout.on(
child.stdout?.on(
'data',
pipeWithPrefix(process.stdout, `[${logPrefix}].out: `),
);
child.stderr.on(
child.stderr?.on(
'data',
pipeWithPrefix(process.stderr, `[${logPrefix}].err: `),
);
@@ -52,7 +57,7 @@ function spawnPiped(cmd, options) {
return child;
}
async function runPlain(cmd, options) {
export async function runPlain(cmd: string[], options?: SpawnOptions) {
try {
const { stdout } = await execFile(cmd[0], cmd.slice(1), {
...options,
@@ -70,8 +75,9 @@ async function runPlain(cmd, options) {
}
}
function handleError(err) {
export function handleError(err: Error & { code?: unknown }) {
process.stdout.write(`${err.name}: ${err.stack || err.message}\n`);
if (typeof err.code === 'number') {
process.exit(err.code);
} else {
@@ -85,9 +91,14 @@ function handleError(err) {
* .cancel() is available
* @returns {Promise} Promise of resolution
*/
function waitFor(fn) {
return new Promise(resolve => {
export function waitFor(fn: () => boolean, maxSeconds: number = 120) {
let count = 0;
return new Promise((resolve, reject) => {
const handle = setInterval(() => {
if (count++ > maxSeconds * 10) {
reject(new Error('Timed out while waiting for condition'));
return;
}
if (fn()) {
clearInterval(handle);
resolve();
@@ -97,7 +108,7 @@ function waitFor(fn) {
});
}
async function waitForExit(child) {
export async function waitForExit(child: ChildProcess) {
if (child.exitCode !== null) {
throw new Error(`Child already exited with code ${child.exitCode}`);
}
@@ -113,10 +124,10 @@ async function waitForExit(child) {
);
}
async function waitForPageWithText(
browser,
path,
text,
export async function waitForPageWithText(
browser: any,
path: string,
text: string,
{ intervalMs = 1000, maxLoadAttempts = 240, maxFindTextAttempts = 3 } = {},
) {
let loadAttempts = 0;
@@ -163,16 +174,6 @@ async function waitForPageWithText(
}
}
function print(msg) {
export function print(msg: string) {
return process.stdout.write(`${msg}\n`);
}
module.exports = {
spawnPiped,
runPlain,
handleError,
waitFor,
waitForExit,
waitForPageWithText,
print,
};
@@ -14,16 +14,12 @@
* limitations under the License.
*/
module.exports = {
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
optionalDependencies: true,
peerDependencies: true,
bundledDependencies: true,
},
],
require('ts-node').register({
transpileOnly: true,
project: require('path').resolve(__dirname, '../../../tsconfig.json'),
compilerOptions: {
module: 'CommonJS',
},
};
});
require('./e2e-test');
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
declare module 'zombie';
declare module 'pgtools';