Refactor create-app tasks and introduce regression tests

- Tasks were moved from the script entrypoint to the lib directory
- Doc comments were added for each task function defined in `src/lib/tasks.ts`
- The `yarn test` script was added to package.json
- Unit tests were written for each task -- verying file operations using fs-mock

Signed-off-by: Colton Padden <colton.padden@fastmail.com>
This commit is contained in:
Colton Padden
2021-10-28 19:57:23 -04:00
parent 01a0a39521
commit 2163e83fa2
5 changed files with 342 additions and 79 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Refactor and add regression tests for create-app tasks
+2
View File
@@ -23,6 +23,7 @@
"scripts": {
"build": "backstage-cli build --outputs cjs",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
"start": "nodemon --"
},
@@ -40,6 +41,7 @@
"@types/fs-extra": "^9.0.1",
"@types/inquirer": "^7.3.1",
"@types/recursive-readdir": "^2.2.0",
"mock-fs": "^5.1.1",
"ts-node": "^10.0.0"
},
"peerDependencies": {
+14 -78
View File
@@ -14,85 +14,21 @@
* limitations under the License.
*/
import fs from 'fs-extra';
import { promisify } from 'util';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import { findPaths } from '@backstage/cli-common';
import os from 'os';
import { Task, templatingTask } from './lib/tasks';
const exec = promisify(execCb);
async function checkAppExists(rootDir: string, name: string) {
await Task.forItem('checking', name, async () => {
const destination = resolvePath(rootDir, name);
if (await fs.pathExists(destination)) {
const existing = chalk.cyan(destination.replace(`${rootDir}/`, ''));
throw new Error(
`A directory with the same name already exists: ${existing}\nPlease try again with a different app name`,
);
}
});
}
async function checkPathExists(path: string) {
await Task.forItem('checking', path, async () => {
try {
await fs.mkdirs(path);
} catch (error) {
// will fail if a file already exists at given `path`
throw new Error(`Failed to create app directory: ${error.message}`);
}
});
}
async function createTemporaryAppFolder(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
} catch (error) {
throw new Error(`Failed to create temporary app directory, ${error}`);
}
});
}
async function buildApp(appDir: string) {
const runCmd = async (cmd: string) => {
await Task.forItem('executing', cmd, async () => {
process.chdir(appDir);
await exec(cmd).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
});
});
};
await runCmd('yarn install');
await runCmd('yarn tsc');
}
async function moveApp(tempDir: string, destination: string, id: string) {
await Task.forItem('moving', id, async () => {
await fs
.move(tempDir, destination)
.catch(error => {
throw new Error(
`Failed to move app from ${tempDir} to ${destination}: ${error.message}`,
);
})
.finally(() => {
// remove temporary files on both success and failure
fs.removeSync(tempDir);
});
});
}
import {
Task,
buildAppTask,
checkAppExistsTask,
checkPathExistsTask,
createTemporaryAppFolderTask,
moveAppTask,
templatingTask,
} from './lib/tasks';
export default async (cmd: Command): Promise<void> => {
/* eslint-disable-next-line no-restricted-syntax */
@@ -143,7 +79,7 @@ export default async (cmd: Command): Promise<void> => {
// Template directly to specified path
Task.section('Checking that supplied path exists');
await checkPathExists(appDir);
await checkPathExistsTask(appDir);
Task.section('Preparing files');
await templatingTask(templateDir, cmd.path, answers);
@@ -151,21 +87,21 @@ export default async (cmd: Command): Promise<void> => {
// Template to temporary location, and then move files
Task.section('Checking if the directory is available');
await checkAppExists(paths.targetDir, answers.name);
await checkAppExistsTask(paths.targetDir, answers.name);
Task.section('Creating a temporary app directory');
await createTemporaryAppFolder(tempDir);
await createTemporaryAppFolderTask(tempDir);
Task.section('Preparing files');
await templatingTask(templateDir, tempDir, answers);
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
await moveAppTask(tempDir, appDir, answers.name);
}
if (!cmd.skipInstall) {
Task.section('Building the app');
await buildApp(appDir);
await buildAppTask(appDir);
}
Task.log();
+208
View File
@@ -0,0 +1,208 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 fs from 'fs-extra';
import mockFs from 'mock-fs';
import child_process, { ChildProcess } from 'child_process';
import path from 'path';
import {
buildAppTask,
checkAppExistsTask,
checkPathExistsTask,
createTemporaryAppFolderTask,
moveAppTask,
templatingTask,
} from './tasks';
jest.mock('child_process');
beforeEach(() => {
mockFs({
'projects/my-module.ts': '',
'projects/dir/my-file.txt': '',
'tmp/mockApp/.gitignore': '',
'tmp/mockApp/package.json': '',
'tmp/mockApp/packages/app/package.json': '',
// load templates into mock filesystem
'templates/': mockFs.load(path.resolve(__dirname, '../../templates/')),
});
});
afterEach(() => {
mockFs.restore();
});
describe('checkAppExistsTask', () => {
it('should do nothing if the directory does not exist', async () => {
const dir = 'projects/';
const name = 'MyNewApp';
await expect(checkAppExistsTask(dir, name)).resolves.not.toThrow();
});
it('should throw an error when a file of the same name exists', async () => {
const dir = 'projects/';
const name = 'my-module.ts';
await expect(checkAppExistsTask(dir, name)).rejects.toThrow(
'already exists',
);
});
it('should throw an error when a directory of the same name exists', async () => {
const dir = 'projects/';
const name = 'dir';
await expect(checkAppExistsTask(dir, name)).rejects.toThrow(
'already exists',
);
});
});
describe('checkPathExistsTask', () => {
it('should create a directory at the given path', async () => {
const appDir = 'projects/newProject';
await expect(checkPathExistsTask(appDir)).resolves.not.toThrow();
expect(fs.existsSync(appDir)).toBe(true);
});
it('should do nothing if a directory of the same name exists', async () => {
const appDir = 'projects/dir';
await expect(checkPathExistsTask(appDir)).resolves.not.toThrow();
expect(fs.existsSync(appDir)).toBe(true);
});
it('should fail if a file of the same name exists', async () => {
await expect(checkPathExistsTask('projects/my-module.ts')).rejects.toThrow(
'already exists',
);
});
});
describe('createTemporaryAppFolderTask', () => {
it('should create a directory at a given path', async () => {
const tempDir = 'projects/tmpFolder';
await expect(createTemporaryAppFolderTask(tempDir)).resolves.not.toThrow();
expect(fs.existsSync(tempDir)).toBe(true);
});
it('should fail if a directory of the same name exists', async () => {
const tempDir = 'projects/dir';
await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow(
'file already exists',
);
});
it('should fail if a file of the same name exists', async () => {
const tempDir = 'projects/dir/my-file.txt';
await expect(createTemporaryAppFolderTask(tempDir)).rejects.toThrow(
'file already exists',
);
});
});
describe('buildAppTask', () => {
it('should change to `appDir` and run `yarn install` and `yarn tsc`', async () => {
const mockChdir = jest.spyOn(process, 'chdir');
const mockExec = jest.spyOn(child_process, 'exec');
// requires callback implementation to support `promisify` wrapper
// https://stackoverflow.com/a/60579617/10044859
mockExec.mockImplementation((_: string, callback?: any): ChildProcess => {
callback(null, 'stdout', 'stderr');
return;
});
const appDir = 'projects/dir';
await expect(buildAppTask(appDir)).resolves.not.toThrow();
expect(mockChdir).toBeCalledTimes(2);
expect(mockChdir).toHaveBeenNthCalledWith(1, appDir);
expect(mockChdir).toHaveBeenNthCalledWith(2, appDir);
expect(mockExec).toBeCalledTimes(2);
expect(mockExec).toHaveBeenNthCalledWith(
1,
'yarn install',
expect.any(Function),
);
expect(mockExec).toHaveBeenNthCalledWith(
2,
'yarn tsc',
expect.any(Function),
);
});
it('should fail if project directory does not exist', async () => {
const appDir = 'projects/missingProject';
await expect(buildAppTask(appDir)).rejects.toThrow(
'no such file or directory',
);
});
});
describe('moveAppTask', () => {
const tempDir = 'tmp/mockApp/';
const id = 'myApp';
it('should move all files in the temp dir to the target dir', async () => {
const destination = 'projects/mockApp';
await moveAppTask(tempDir, destination, id);
expect(fs.existsSync('projects/mockApp/.gitignore')).toBe(true);
expect(fs.existsSync('projects/mockApp/package.json')).toBe(true);
expect(fs.existsSync('projects/mockApp/packages/app/package.json')).toBe(
true,
);
});
it('should fail to move files if destination already exists', async () => {
const destination = 'projects';
await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow(
'dest already exists',
);
});
it('should remove temporary files if move succeeded', async () => {
const destination = 'projects/mockApp';
await moveAppTask(tempDir, destination, id);
expect(fs.existsSync('tmp/mockApp')).toBe(false);
});
it('should remove temporary files if move failed', async () => {
const destination = 'projects';
await expect(moveAppTask(tempDir, destination, id)).rejects.toThrow();
expect(fs.existsSync('tmp/mockApp')).toBe(false);
});
});
describe('templatingTask', () => {
it('should generate a project populating context parameters', async () => {
const templateDir = 'templates/default-app';
const destinationDir = 'templatedApp';
const context = {
name: 'SuperCoolBackstageInstance',
dbTypeSqlite: true,
};
await templatingTask(templateDir, destinationDir, context);
expect(fs.existsSync('templatedApp/package.json')).toBe(true);
expect(fs.existsSync('templatedApp/.dockerignore')).toBe(true);
// catalog was populated with `context.name`
expect(
fs.readFileSync('templatedApp/catalog-info.yaml', 'utf-8'),
).toContain('name: SuperCoolBackstageInstance');
// backend dependencies include `sqlite3` from `context.SQLite`
expect(
fs.readFileSync('templatedApp/packages/backend/package.json', 'utf-8'),
).toContain('"sqlite3"');
});
});
+113 -1
View File
@@ -18,11 +18,14 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { basename, dirname, resolve as resolvePath } from 'path';
import { exec as execCb } from 'child_process';
import { packageVersions } from './versions';
import { promisify } from 'util';
const TASK_NAME_MAX_LENGTH = 14;
const exec = promisify(execCb);
export class Task {
static log(name: string = '') {
@@ -65,6 +68,13 @@ export class Task {
}
}
/**
* Generate a templated backstage project
*
* @param templateDir - location containing template files
* @param destinationDir - location to save templated project
* @param context - template parameters
*/
export async function templatingTask(
templateDir: string,
destinationDir: string,
@@ -116,3 +126,105 @@ export async function templatingTask(
}
}
}
/**
* Verify that application target does not already exist
*
* @param rootDir - The directory to create application folder `name`
* @param name - The specified name of the application
* @Throws Error - If directory with name of `destination` already exists
*/
export async function checkAppExistsTask(rootDir: string, name: string) {
await Task.forItem('checking', name, async () => {
const destination = resolvePath(rootDir, name);
if (await fs.pathExists(destination)) {
const existing = chalk.cyan(destination.replace(`${rootDir}/`, ''));
throw new Error(
`A directory with the same name already exists: ${existing}\nPlease try again with a different app name`,
);
}
});
}
/**
* Verify that application `path` exists, otherwise create the directory
*
* @param {string} path - target to create directory
* @throws {Error} if `path` is a file, or `fs.mkdir` fails
*/
export async function checkPathExistsTask(path: string) {
await Task.forItem('checking', path, async () => {
try {
await fs.mkdirs(path);
} catch (error) {
// will fail if a file already exists at given `path`
throw new Error(`Failed to create app directory: ${error.message}`);
}
});
}
/**
* Create a folder to store templated files
*
* @param {string} tempDir - target temporary directory
* @throws {Error} if `fs.mkdir` fails
*/
export async function createTemporaryAppFolderTask(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
} catch (error) {
throw new Error(`Failed to create temporary app directory, ${error}`);
}
});
}
/**
* Run `yarn install` and `run tsc` in application directory
*
* @param {string} appDir - location of application to build
*/
export async function buildAppTask(appDir: string) {
const runCmd = async (cmd: string) => {
await Task.forItem('executing', cmd, async () => {
process.chdir(appDir);
await exec(cmd).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
});
});
};
await runCmd('yarn install');
await runCmd('yarn tsc');
}
/**
* Move temporary directory to destination application folder
*
* @param {string} tempDir source path to copy files from
* @param {string} destination target path to copy files
* @param {string} id
* @throws {Error} if `fs.move` fails
*/
export async function moveAppTask(
tempDir: string,
destination: string,
id: string,
) {
await Task.forItem('moving', id, async () => {
await fs
.move(tempDir, destination)
.catch(error => {
throw new Error(
`Failed to move app from ${tempDir} to ${destination}: ${error.message}`,
);
})
.finally(() => {
// remove temporary files on both success and failure
fs.removeSync(tempDir);
});
});
}