Skip to content

Commit

Permalink
Refactor create-app tasks and introduce regression tests
Browse files Browse the repository at this point in the history
- 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>
  • Loading branch information
cmpadden committed Nov 11, 2021
1 parent 01a0a39 commit 2163e83
Show file tree
Hide file tree
Showing 5 changed files with 342 additions and 79 deletions.
5 changes: 5 additions & 0 deletions .changeset/many-sloths-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---

Refactor and add regression tests for create-app tasks
2 changes: 2 additions & 0 deletions packages/create-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 --"
},
Expand All @@ -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": {
Expand Down
92 changes: 14 additions & 78 deletions packages/create-app/src/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -143,29 +79,29 @@ 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);
} else {
// 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();
Expand Down
208 changes: 208 additions & 0 deletions packages/create-app/src/lib/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});

0 comments on commit 2163e83

Please sign in to comment.