Merge pull request #570 from spotify/rugvip/cli-e2e-tweaks

cli e2e test tweaks
This commit is contained in:
Patrik Oldsberg
2020-04-17 09:25:15 +02:00
committed by GitHub
11 changed files with 84 additions and 69 deletions
+29
View File
@@ -0,0 +1,29 @@
/*
* 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,
},
],
},
};
+81
View File
@@ -0,0 +1,81 @@
/*
* 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 { resolve: resolvePath } = require('path');
const Browser = require('zombie');
const {
spawnPiped,
handleError,
waitForPageWithText,
waitForExit,
print,
} = require('./helpers');
const createTestApp = require('./createTestApp');
const createTestPlugin = require('./createTestPlugin');
Browser.localhost('localhost', 3000);
async function createTempDir() {
return fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
}
async function main() {
process.env.BACKSTAGE_E2E_CLI_TEST = 'true';
const workDir = process.env.CI ? process.cwd() : await createTempDir();
process.stdout.write(`Initial directory: ${process.cwd()}\n`);
process.chdir(workDir);
process.stdout.write(`Working directory: ${process.cwd()}\n`);
await createTestApp();
const appDir = resolvePath(workDir, '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');
await waitForPageWithText(
browser,
'/test-plugin',
'Welcome to test-plugin!',
);
print('Both App and Plugin loaded correctly');
} finally {
startApp.kill();
}
await waitForExit(startApp);
print('All tests done');
process.exit(0);
}
process.on('unhandledRejection', handleError);
main(process.argv.slice(2)).catch(handleError);
+44
View File
@@ -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 { resolve: resolvePath } = require('path');
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestApp() {
const cliPath = resolvePath(__dirname, '../bin/backstage-cli');
print('Creating a Backstage App');
const createApp = spawnPiped(['node', cliPath, 'create-app']);
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;
+44
View File
@@ -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;
+139
View File
@@ -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,
};
+3 -1
View File
@@ -22,6 +22,7 @@
"build": "backstage-cli build-cache -- tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"test:e2e": "node e2e-test/cli-e2e-test.js",
"clean": "backstage-cli clean",
"start": "nodemon ."
},
@@ -41,7 +42,8 @@
"del": "^5.1.0",
"nodemon": "^2.0.2",
"ts-node": "^8.6.2",
"tsconfig-paths": "^3.9.0"
"tsconfig-paths": "^3.9.0",
"zombie": "^6.1.4"
},
"bin": {
"backstage-cli": "bin/backstage-cli"
@@ -30,7 +30,7 @@ export async function withCache(
buildFunc: () => Promise<void>,
): Promise<void> {
const key = await Cache.readInputKey(options.inputs);
if (!key || process.env.BACKSTAGE_E2E_CLI_TEST) {
if (!key) {
print('input directory is dirty, skipping cache');
await fs.remove(options.output);
await buildFunc();
@@ -86,26 +86,22 @@ export async function moveApp(
});
}
async function addPackageResolutions(rootDir: string, appDir: string) {
process.chdir(appDir);
const packageFileContent = await fs.readFile('package.json', 'utf-8');
async function addPackageResolutions(appDir: string) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const packageFileContent = await fs.readFile(pkgJsonPath, 'utf-8');
const packageFileJson = JSON.parse(packageFileContent);
if (packageFileJson.resolutions) {
throw new Error('package.json already contains resolutions');
}
packageFileJson.resolutions = {};
packageFileJson.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);
const pkgPath = paths.resolveOwnRoot('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 => {
await fs.writeFile(pkgJsonPath, newContents, 'utf-8').catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
@@ -157,10 +153,7 @@ export default async () => {
// 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);
await addPackageResolutions(appDir);
}
Task.section('Building the app');
+31
View File
@@ -25,6 +25,9 @@ export type Paths = {
// Root dir of the cli itself, containing package.json
ownDir: string;
// Monorepo root dir of the cli itself. Only accessible when running inside Backstage repo.
ownRoot: string;
// The location of the app that the cli is being executed in
targetDir: string;
@@ -34,6 +37,9 @@ export type Paths = {
// Resolve a path relative to own repo
resolveOwn: ResolveFunc;
// Resolve a path relative to own monorepo root. Only accessible when running inside Backstage repo.
resolveOwnRoot: ResolveFunc;
// Resolve a path relative to the app
resolveTarget: ResolveFunc;
@@ -91,10 +97,31 @@ export function findOwnDir() {
return resolvePath(__dirname, path);
}
// Finds the root of the monorepo that the cli exists in. Only accessible when running inside Backstage repo.
export function findOwnRootPath(ownDir: string) {
const isLocal = fs.pathExistsSync(resolvePath(ownDir, 'src'));
if (!isLocal) {
throw new Error(
'Tried to access monorepo package root dir outside of Backstage repository',
);
}
return resolvePath(ownDir, '../..');
}
export function findPaths(): Paths {
const ownDir = findOwnDir();
const targetDir = fs.realpathSync(process.cwd());
// Lazy load this as it will throw an error if we're not inside the Backstage repo.
let ownRoot = '';
const getOwnRoot = () => {
if (!ownRoot) {
ownRoot = findOwnRootPath(ownDir);
}
return ownRoot;
};
// We're not always running in a monorepo, so we lazy init this to only crash commands
// that require a monorepo when we're not in one.
let targetRoot = '';
@@ -107,11 +134,15 @@ export function findPaths(): Paths {
return {
ownDir,
get ownRoot() {
return getOwnRoot();
},
targetDir,
get targetRoot() {
return getTargetRoot();
},
resolveOwn: (...paths) => resolvePath(ownDir, ...paths),
resolveOwnRoot: (...paths) => resolvePath(getOwnRoot(), ...paths),
resolveTarget: (...paths) => resolvePath(targetDir, ...paths),
resolveTargetRoot: (...paths) => resolvePath(getTargetRoot(), ...paths),
};