diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index d571703294..dad8437613 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/cli.yml' - 'packages/cli/**' - 'packages/core/**' + - 'scripts/**' jobs: build: @@ -40,19 +41,31 @@ jobs: - name: yarn install run: yarn install --frozen-lockfile - run: yarn build - # This creates a new plugin and pollutes the workspace, so it should be run last. - - name: verify app serve and plugin creation on Windows + # generate temp directory + - name: generate tempdir + id: generate_tempdir + run: echo "::set-output name=tempdir::$(node scripts/generateTempDir.js)" + # This creates a new app and plugin which pollutes the workspace, so it should be run last. + - name: verify app and plugin creation on Windows + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} if: runner.os == 'Windows' - run: node scripts/cli-e2e-test.js - - name: verify app serve and plugin creation on Linux + run: node ${{ github.workspace }}/scripts/cli-e2e-test.js + env: + BACKSTAGE_E2E_CLI_TEST: true + - name: verify app and plugin creation on Linux + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 - node scripts/cli-e2e-test.js - - name: yarn lint, test after plugin creation - working-directory: plugins/test-plugin - run: | - yarn lint - yarn test + node ${{ github.workspace }}/scripts/cli-e2e-test.js env: - CI: true + BACKSTAGE_E2E_CLI_TEST: true + # This should lint and test both an app and a plugin + - name: yarn lint, test after creation + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} + run: | + cd test-app + yarn lint:all + yarn test:all + env: + BACKSTAGE_E2E_CLI_TEST: true diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli index a8ba74f28a..5288f913d9 100755 --- a/packages/cli/bin/backstage-cli +++ b/packages/cli/bin/backstage-cli @@ -19,7 +19,8 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); -if (!isLocal) { + +if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { // src-relative imports are a pain to get to work with plain tsc compilation, as the // transpiled code will maintain the imports as they are in the source. Which means an // import for `helpers/paths` will start like that in the output, which won't work in NodeJS. diff --git a/packages/cli/src/commands/build-cache/index.ts b/packages/cli/src/commands/build-cache/index.ts index a8a1a27e9d..a825a7f415 100644 --- a/packages/cli/src/commands/build-cache/index.ts +++ b/packages/cli/src/commands/build-cache/index.ts @@ -30,7 +30,7 @@ export async function withCache( buildFunc: () => Promise, ): Promise { const key = await Cache.readInputKey(options.inputs); - if (!key) { + if (!key || process.env.BACKSTAGE_E2E_CLI_TEST) { print('input directory is dirty, skipping cache'); await fs.remove(options.output); await buildFunc(); diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index 5863d9e705..219406704d 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -86,6 +86,34 @@ export async function moveApp( }); } +async function addPackageResolutions(rootDir: string, appDir: string) { + process.chdir(appDir); + + const packageFileContent = await fs.readFile('package.json', 'utf-8'); + const packageFileJson = JSON.parse(packageFileContent); + + if (packageFileJson.resolutions) { + throw new Error('package.json already contains resolutions'); + } + packageFileJson.resolutions = {}; + + const packages = ['cli', 'core', 'test-utils', 'test-utils-core', 'theme']; + + for (const pkg of packages) { + await Task.forItem('adding', `${pkg} link to package.json`, async () => { + const pkgPath = require('path').join(rootDir, 'packages', pkg); + packageFileJson.resolutions[`@backstage/${pkg}`] = `file:${pkgPath}`; + const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`; + + await fs.writeFile('package.json', newContents, 'utf-8').catch(error => { + throw new Error( + `Failed to add resolutions to package.json: ${error.message}`, + ); + }); + }); + } +} + export default async () => { const questions: Question[] = [ { @@ -126,6 +154,15 @@ export default async () => { Task.section('Moving to final location'); await moveApp(tempDir, appDir, answers.name); + // e2e testing needs special treatment + if (process.env.BACKSTAGE_E2E_CLI_TEST) { + Task.section('Linking packages locally for e2e tests'); + const rootDir = process.env.CI + ? resolvePath(process.env.GITHUB_WORKSPACE!) + : resolvePath(__dirname, '..', '..', '..'); + await addPackageResolutions(rootDir, appDir); + } + Task.section('Building the app'); await buildApp(appDir); @@ -134,6 +171,7 @@ export default async () => { chalk.green(`🥇 Successfully created ${chalk.cyan(answers.name)}`), ); Task.log(); + Task.exit(); } catch (error) { Task.error(error.message); @@ -143,5 +181,6 @@ export default async () => { Task.section('Cleanup'); await cleanUp(tempDir); Task.error('🔥 Failed to create app!'); + Task.exit(1); } }; diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index df8b71f619..51f749a6ac 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -268,6 +268,7 @@ export default async () => { )}`, ); Task.log(); + Task.exit(); } catch (error) { Task.error(error.message); @@ -277,5 +278,6 @@ export default async () => { Task.section('Cleanup'); await cleanUp(tempDir); Task.error('🔥 Failed to create plugin!'); + Task.exit(1); } }; diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts index fe2675e0e0..c991fa3efb 100644 --- a/packages/cli/src/commands/plugin/rollup.config.ts +++ b/packages/cli/src/commands/plugin/rollup.config.ts @@ -46,6 +46,7 @@ export default { json(), typescript({ include: `${paths.resolveTarget('src')}/**/*.{js,jsx,ts,tsx}`, + clean: true, }), ], } as RollupWatchOptions; diff --git a/packages/cli/src/helpers/paths.ts b/packages/cli/src/helpers/paths.ts index 746b3f6ea8..173e77e1c5 100644 --- a/packages/cli/src/helpers/paths.ts +++ b/packages/cli/src/helpers/paths.ts @@ -53,7 +53,7 @@ export function findRootPath(topPath: string): string { try { const contents = fs.readFileSync(packagePath, 'utf8'); const data = JSON.parse(contents); - if (data.name === 'root') { + if (data.name === 'root' || data.name.includes('backstage-e2e')) { return path; } } catch (error) { diff --git a/packages/cli/src/helpers/tasks.ts b/packages/cli/src/helpers/tasks.ts index 3cba4d262f..0753301b78 100644 --- a/packages/cli/src/helpers/tasks.ts +++ b/packages/cli/src/helpers/tasks.ts @@ -37,6 +37,10 @@ export class Task { process.stdout.write(`\n ${title}\n`); } + static exit(code: number = 0) { + process.exit(code); + } + static async forItem( task: string, item: string, diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs index a5b2b07b96..940091380a 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -5,6 +5,7 @@ "dependencies": { "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", "@backstage/cli": "^{{version}}", "@backstage/core": "^{{version}}", "@backstage/theme": "^{{version}}", @@ -17,7 +18,8 @@ "plugin-welcome": "0.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", - "react-router-dom": "^5.1.2" + "react-router-dom": "^5.1.2", + "react-use": "^13.24.0" }, "scripts": { "start": "backstage-cli app:serve", diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 48058550d9..4af26e2e41 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -15,168 +15,75 @@ */ const { resolve: resolvePath } = require('path'); -const childProcess = require('child_process'); -const { spawn } = childProcess; const Browser = require('zombie'); -const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/; +const { + spawnPiped, + handleError, + waitForPageWithText, + waitForExit, + print, +} = require('./helpers'); + +const createTestApp = require('./createTestApp'); +const createTestPlugin = require('./createTestPlugin'); +const generateTempDir = require('./generateTempDir.js'); Browser.localhost('localhost', 3000); async function main() { - process.env.CI = 'true'; + process.env.BACKSTAGE_E2E_CLI_TEST = 'true'; - const projectDir = resolvePath(__dirname, '..'); - process.chdir(projectDir); + const rootDir = process.env.CI + ? resolvePath(process.env.GITHUB_WORKSPACE) + : resolvePath(__dirname, '..'); - const start = spawnPiped(['yarn', 'start']); + const tempDir = process.env.CI ? process.cwd() : await generateTempDir(); + + process.stdout.write(`Initial directory: ${process.cwd()}\n`); + process.chdir(tempDir); + process.stdout.write(`Temp directory: ${process.cwd()}\n`); + + await waitForExit(spawnPiped(['yarn', 'init --yes'])); + + const createCmdPath = require('path').join( + rootDir, + 'packages', + 'cli', + 'bin', + 'backstage-cli', + ); + await createTestApp(`${createCmdPath} create-app`); + + const appDir = resolvePath(tempDir, 'test-app'); + process.chdir(appDir); + process.stdout.write(`App directory: ${appDir}\n`); + + await createTestPlugin(); + + print('Starting the app'); + const startApp = spawnPiped(['yarn', 'start']); try { const browser = new Browser(); await waitForPageWithText(browser, '/', 'Welcome to Backstage'); - print('Backstage loaded correctly, creating plugin'); - - const createPlugin = spawnPiped(['yarn', 'create-plugin']); - - let stdout = ''; - createPlugin.stdout.on('data', data => { - stdout = stdout + data.toString('utf8'); - }); - - await waitFor(() => stdout.includes('Enter an ID for the plugin')); - createPlugin.stdin.write('test-plugin\n'); - - await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); - createPlugin.stdin.write('@someuser\n'); - - print('Waiting for plugin create script to be done'); - await waitForExit(createPlugin); - - print('Plugin create script is done, waiting for plugin page to load'); await waitForPageWithText( browser, '/test-plugin', 'Welcome to test-plugin!', ); - print('Test plugin loaded correctly, exiting'); + print('Both App and Plugin loaded correctly'); } finally { - start.kill(); + startApp.kill(); } - await waitForExit(start); + await waitForExit(startApp); + + print('All tests done'); process.exit(0); } -function waitFor(fn) { - return new Promise(resolve => { - const handle = setInterval(() => { - if (fn()) { - clearInterval(handle); - resolve(); - return; - } - }, 100); - }); -} - -function print(msg) { - return process.stdout.write(`${msg}\n`); -} - -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 { - resolve(); - } - }), - ); -} - -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); - child.on('exit', code => { - if (code) { - print(`Child '${cmd.join(' ')}' exited with code ${code}`); - process.exit(code); - } - }); - child.stdout.on( - 'data', - pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `), - ); - child.stderr.on( - 'data', - pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `), - ); - - return child; -} - -async function waitForPageWithText( - browser, - path, - text, - { intervalMs = 1000, maxAttempts = 120 } = {}, -) { - let attempts = 0; - for (;;) { - try { - await new Promise(resolve => setTimeout(resolve, intervalMs)); - await browser.visit(path); - break; - } catch (error) { - if (error.message.match(EXPECTED_LOAD_ERRORS)) { - attempts++; - if (attempts > maxAttempts) { - throw new Error( - `Failed to load page '${path}', max number of attempts reached`, - ); - } - } else { - throw error; - } - } - } - - const escapedText = text.replace(/"/g, '\\"'); - browser.assert.evaluate( - `Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`, - true, - `expected to find text ${text}`, - ); -} - -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); - } -} - process.on('unhandledRejection', handleError); main(process.argv.slice(2)).catch(handleError); diff --git a/scripts/createTestApp.js b/scripts/createTestApp.js new file mode 100644 index 0000000000..80f7bf3b18 --- /dev/null +++ b/scripts/createTestApp.js @@ -0,0 +1,41 @@ +/* + * 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 { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); + +async function createTestApp(cmd) { + print('Creating a Backstage App'); + const createApp = spawnPiped(['node', cmd]); + + try { + let stdout = ''; + createApp.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); + + await waitFor(() => stdout.includes('Enter a name for the app')); + createApp.stdin.write('test-app\n'); + + print('Waiting for app create script to be done'); + await waitForExit(createApp); + + print('Test app created'); + } finally { + createApp.kill(); + } +} + +module.exports = createTestApp; diff --git a/scripts/createTestPlugin.js b/scripts/createTestPlugin.js new file mode 100644 index 0000000000..59e8d6dd77 --- /dev/null +++ b/scripts/createTestPlugin.js @@ -0,0 +1,44 @@ +/* + * 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 { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); + +async function createTestPlugin() { + print('Creating a Backstage Plugin'); + const createPlugin = spawnPiped(['yarn', 'create-plugin']); + + try { + let stdout = ''; + createPlugin.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); + + await waitFor(() => stdout.includes('Enter an ID for the plugin')); + createPlugin.stdin.write('test-plugin\n'); + + // await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); + // createPlugin.stdin.write('@someuser\n'); + + print('Waiting for plugin create script to be done'); + await waitForExit(createPlugin); + + print('Test plugin created'); + } finally { + createPlugin.kill(); + } +} + +module.exports = createTestPlugin; diff --git a/scripts/generateTempDir.js b/scripts/generateTempDir.js new file mode 100644 index 0000000000..e455a29320 --- /dev/null +++ b/scripts/generateTempDir.js @@ -0,0 +1,30 @@ +/* + * 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 { handleError } = require('./helpers'); + +async function generateTempDir() { + const tempDir = await require('fs-extra').mkdtemp( + require('path').join(require('os').tmpdir(), 'backstage-e2e-'), + ); + process.stdout.write(tempDir); + return tempDir; +} + +module.exports = generateTempDir; + +process.on('unhandledRejection', handleError); +generateTempDir().catch(handleError); diff --git a/scripts/helpers.js b/scripts/helpers.js new file mode 100644 index 0000000000..7e34f10a63 --- /dev/null +++ b/scripts/helpers.js @@ -0,0 +1,139 @@ +/* + * 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 childProcess = require('child_process'); +const { spawn } = childProcess; + +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); + child.on('exit', code => { + if (code) { + print(`Child '${cmd.join(' ')}' exited with code ${code}`); + process.exit(code); + } + }); + child.stdout.on( + 'data', + pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `), + ); + child.stderr.on( + 'data', + pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `), + ); + + return child; +} + +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); + } +} + +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, maxAttempts = 240 } = {}, +) { + let attempts = 0; + for (;;) { + try { + await new Promise(resolve => setTimeout(resolve, intervalMs)); + await browser.visit(path); + break; + } catch (error) { + if (error.message.match(EXPECTED_LOAD_ERRORS)) { + attempts++; + if (attempts > maxAttempts) { + throw new Error( + `Failed to load page '${path}', max number of attempts reached`, + ); + } + } else { + throw error; + } + } + } + + const escapedText = text.replace(/"/g, '\\"'); + browser.assert.evaluate( + `Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`, + true, + `expected to find text ${text}`, + ); +} + +function print(msg) { + return process.stdout.write(`${msg}\n`); +} + +module.exports = { + spawnPiped, + handleError, + waitFor, + waitForExit, + waitForPageWithText, + print, +};