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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Refactor and add regression tests for create-app tasks
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user