diff --git a/.github/workflows/cli-win.yml b/.github/workflows/e2e-win.yml similarity index 77% rename from .github/workflows/cli-win.yml rename to .github/workflows/e2e-win.yml index 4e7a186282..5f75f3d9c8 100644 --- a/.github/workflows/cli-win.yml +++ b/.github/workflows/e2e-win.yml @@ -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 diff --git a/.github/workflows/cli.yml b/.github/workflows/e2e.yml similarity index 87% rename from .github/workflows/cli.yml rename to .github/workflows/e2e.yml index 158310b5ea..45b9d7e2d8 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/packages/cli/package.json b/packages/cli/package.json index e5a8eafc81..0291e720d2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/e2e-test/.eslintrc.js b/packages/e2e-test/.eslintrc.js new file mode 100644 index 0000000000..ce2592e03b --- /dev/null +++ b/packages/e2e-test/.eslintrc.js @@ -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, + }, + ], + }, +}; diff --git a/packages/e2e-test/README.md b/packages/e2e-test/README.md new file mode 100644 index 0000000000..6cf50283c5 --- /dev/null +++ b/packages/e2e-test/README.md @@ -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) diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json new file mode 100644 index 0000000000..8e6c730431 --- /dev/null +++ b/packages/e2e-test/package.json @@ -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" + } +} diff --git a/packages/cli/e2e-test/cli-e2e-test.js b/packages/e2e-test/src/e2e-test.ts similarity index 76% rename from packages/cli/e2e-test/cli-e2e-test.js rename to packages/e2e-test/src/e2e-test.ts index c4004f7c46..7377655497 100644 --- a/packages/cli/e2e-test/cli-e2e-test.js +++ b/packages/e2e-test/src/e2e-test.ts @@ -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(); + 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() + .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); diff --git a/packages/e2e-test/src/helpers.test.ts b/packages/e2e-test/src/helpers.test.ts new file mode 100644 index 0000000000..196241f9e0 --- /dev/null +++ b/packages/e2e-test/src/helpers.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/e2e-test/helpers.js b/packages/e2e-test/src/helpers.ts similarity index 80% rename from packages/cli/e2e-test/helpers.js rename to packages/e2e-test/src/helpers.ts index 580f53cbe8..b59de75860 100644 --- a/packages/cli/e2e-test/helpers.js +++ b/packages/e2e-test/src/helpers.ts @@ -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, -}; diff --git a/packages/cli/e2e-test/.eslintrc.js b/packages/e2e-test/src/index.js similarity index 70% rename from packages/cli/e2e-test/.eslintrc.js rename to packages/e2e-test/src/index.js index 274c7426b8..90f8030a0e 100644 --- a/packages/cli/e2e-test/.eslintrc.js +++ b/packages/e2e-test/src/index.js @@ -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'); diff --git a/packages/e2e-test/src/types.d.ts b/packages/e2e-test/src/types.d.ts new file mode 100644 index 0000000000..0ae86490ee --- /dev/null +++ b/packages/e2e-test/src/types.d.ts @@ -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';