Skip to content

Commit

Permalink
Add jest tests for create-toolpad-app (#1965)
Browse files Browse the repository at this point in the history
Co-authored-by: Jan Potoms <2109932+Janpot@users.noreply.github.com>
  • Loading branch information
bharatkashyap and Janpot authored May 11, 2023
1 parent 5fc7d55 commit 4c34dd5
Show file tree
Hide file tree
Showing 8 changed files with 162 additions and 97 deletions.
5 changes: 5 additions & 0 deletions packages/create-toolpad-app/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Config } from 'jest';

export default {
preset: 'ts-jest/presets/default-esm',
} satisfies Config;
4 changes: 3 additions & 1 deletion packages/create-toolpad-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
"prebuild": "rimraf ./dist",
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --detectOpenHandles --forceExit"
},
"engines": {
"node": ">=16.17"
},
"dependencies": {
"@mui/toolpad-utils": "^0.1.10",
"chalk": "5.2.0",
"execa": "7.1.1",
"inquirer": "9.2.2"
Expand Down
136 changes: 43 additions & 93 deletions packages/create-toolpad-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,17 @@
import * as fs from 'fs/promises';
import path from 'path';
import yargs from 'yargs';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { errorFrom } from '@mui/toolpad-utils/errors';
import { execaCommand } from 'execa';

type PackageManager = 'npm' | 'pnpm' | 'yarn';

type Require<T, K extends keyof T> = T & { [P in K]-?: T[P] };

type Ensure<U, K extends PropertyKey> = K extends keyof U ? Require<U, K> : U & Record<K, unknown>;

declare global {
interface Error {
code?: unknown;
}
}
/**
* Type aware version of Object.protoype.hasOwnProperty.
* See https://fettblog.eu/typescript-hasownproperty/
*/

function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop: Y): obj is Ensure<X, Y> {
return obj.hasOwnProperty(prop);
}

/**
* Limits the length of a string and adds ellipsis if necessary.
*/

function truncate(str: string, maxLength: number, dots: string = '...') {
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength) + dots;
}

/**
* Creates a javascript `Error` from an unkown value if it's not already an error.
* Does a best effort at inferring a message. Intended to be used typically in `catch`
* blocks, as there is no way to enforce only `Error` objects being thrown.
*
* ```
* try {
* // ...
* } catch (rawError) {
* const error = errorFrom(rawError);
* console.assert(error instancof Error);
* }
* ```
*/

function errorFrom(maybeError: unknown): Error {
if (maybeError instanceof Error) {
return maybeError;
}

if (
typeof maybeError === 'object' &&
maybeError &&
hasOwnProperty(maybeError, 'message') &&
typeof maybeError.message! === 'string'
) {
return new Error(maybeError.message, { cause: maybeError });
}

if (typeof maybeError === 'string') {
return new Error(maybeError, { cause: maybeError });
}

const message = truncate(JSON.stringify(maybeError), 500);
return new Error(message, { cause: maybeError });
}

function getPackageManager(): PackageManager {
const userAgent = process.env.npm_config_user_agent;
Expand Down Expand Up @@ -132,8 +75,6 @@ async function isFolderEmpty(pathDir: string): Promise<boolean> {
const packageManager = getPackageManager();

const validatePath = async (relativePath: string): Promise<boolean | string> => {
const { default: chalk } = await import('chalk');

const absolutePath = path.join(process.cwd(), relativePath);

try {
Expand All @@ -159,10 +100,7 @@ const validatePath = async (relativePath: string): Promise<boolean | string> =>
};

// Create a new `package.json` file and install dependencies
const scaffoldProject = async (absolutePath: string): Promise<void> => {
const { default: chalk } = await import('chalk');
const { execaCommand } = await import('execa');

const scaffoldProject = async (absolutePath: string, installFlag: boolean): Promise<void> => {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
Expand All @@ -179,46 +117,54 @@ const scaffoldProject = async (absolutePath: string): Promise<void> => {
build: 'toolpad build',
start: 'toolpad start',
},
dependencies: {
'@mui/toolpad': 'latest',
},
};

await fs.writeFile(path.join(absolutePath, 'package.json'), JSON.stringify(packageJson, null, 2));

// eslint-disable-next-line no-console
console.log(
`${chalk.blue('info')} - Installing the following dependencies: ${chalk.magenta(
'@mui/toolpad',
)}`,
);
// eslint-disable-next-line no-console
console.log();

const installVerb = packageManager === 'yarn' ? 'add' : 'install';
const command = `${packageManager} ${installVerb} @mui/toolpad`;
await execaCommand(command, { stdio: 'inherit', cwd: absolutePath });

// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(`${chalk.green('success')} - Dependencies installed successfully!`);
// eslint-disable-next-line no-console
console.log();
if (installFlag) {
// eslint-disable-next-line no-console
console.log(
`${chalk.blue('info')} - Installing the following dependencies: ${chalk.magenta(
'@mui/toolpad',
)}`,
);
// eslint-disable-next-line no-console
console.log();

const installVerb = packageManager === 'yarn' ? 'add' : 'install';
const command = `${packageManager} ${installVerb} @mui/toolpad`;
await execaCommand(command, { stdio: 'inherit', cwd: absolutePath });

// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(`${chalk.green('success')} - Dependencies installed successfully!`);
// eslint-disable-next-line no-console
console.log();
}
};

// Run the CLI interaction with Inquirer.js
const run = async () => {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');

const args = await yargs(process.argv.slice(2))
.scriptName('create-toolpad-app')
.usage('$0 [path]')
.usage('$0 [path] [options]')
.positional('path', {
type: 'string',
describe: 'The path where the Toolpad project directory will be created',
})
.option('install', {
type: 'boolean',
describe: 'Where to intall dependencies',
default: true,
})
.help().argv;

const pathArg = args._?.[0] as string;
const installFlag = args.install as boolean;

if (pathArg) {
const pathValidOrError = await validatePath(pathArg);
Expand Down Expand Up @@ -248,19 +194,23 @@ const run = async () => {

const absolutePath = path.join(process.cwd(), answers.path || pathArg);

await scaffoldProject(absolutePath);
await scaffoldProject(absolutePath, installFlag);

const changeDirectoryInstruction =
/* `path.relative` is truth-y if the relative path
* between `absolutePath` and `process.cwd()`
* is not empty
*/
path.relative(process.cwd(), absolutePath)
? `cd ${path.relative(process.cwd(), absolutePath)} && `
? ` cd ${path.relative(process.cwd(), absolutePath)}\n`
: '';

const installInstruction = installFlag ? '' : ` ${packageManager} install\n`;

const message = `Run the following to get started: \n\n${chalk.magentaBright(
`${changeDirectoryInstruction}${packageManager}${packageManager === 'yarn' ? '' : ' run'} dev`,
`${changeDirectoryInstruction}${installInstruction} ${packageManager}${
packageManager === 'yarn' ? '' : ' run'
} dev`,
)}`;
// eslint-disable-next-line no-console
console.log(message);
Expand Down
98 changes: 98 additions & 0 deletions packages/create-toolpad-app/tests/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as fs from 'fs/promises';
import { jest } from '@jest/globals';
import * as path from 'path';
import * as url from 'url';
import { execa, ExecaChildProcess } from 'execa';
import readline from 'readline';
import { Readable } from 'stream';
import { once } from 'events';

jest.setTimeout(60000);

const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url));

const cliPath = path.resolve(currentDirectory, '../dist/index.js');

let testDir: string | undefined;
let cp: ExecaChildProcess<string> | undefined;
let toolpadProcess: ExecaChildProcess<string> | undefined;

async function waitForMatch(input: Readable, regex: RegExp): Promise<RegExpExecArray | null> {
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input });

rl.on('line', (line) => {
const match = regex.exec(line);
if (match) {
rl.close();
input.resume();
resolve(match);
}
});
rl.on('error', (err) => reject(err));
rl.on('end', () => resolve(null));
});
}

test('create-toolpad-app can bootstrap a Toolpad app', async () => {
testDir = await fs.mkdtemp(path.resolve(currentDirectory, './test-app-'));
cp = execa(cliPath, [path.basename(testDir)], {
cwd: currentDirectory,
});
const result = await cp;
expect(result.stdout).toMatch('Run the following to get started');
const packageJsonContent = await fs.readFile(path.resolve(testDir, './package.json'), {
encoding: 'utf-8',
});
const packageJson = JSON.parse(packageJsonContent);
expect(packageJson).toEqual(
expect.objectContaining({
dependencies: expect.objectContaining({
'@mui/toolpad': expect.any(String),
}),
scripts: expect.objectContaining({
build: 'toolpad build',
dev: 'toolpad dev',
start: 'toolpad start',
}),
}),
);

toolpadProcess = execa('yarn', ['dev'], {
cwd: testDir,
env: {
FORCE_COLOR: '0',
BROWSER: 'none',
},
});
const { stdout: toolpadDevOutput } = toolpadProcess;

expect(toolpadDevOutput).toBeTruthy();
const match = await waitForMatch(toolpadDevOutput!, /http:\/\/localhost:(\d+)/);

expect(match).toBeTruthy();

const appUrl = match![0];
const res = await fetch(`${appUrl}/health-check`);
expect(res).toHaveProperty('status', 200);
});

afterEach(async () => {
if (toolpadProcess && typeof toolpadProcess.exitCode !== 'number') {
toolpadProcess.kill('SIGKILL');
await once(toolpadProcess, 'exit');
}
});

afterEach(async () => {
if (testDir) {
await fs.rm(testDir, { recursive: true, force: true, maxRetries: 3 });
}
});

afterEach(async () => {
if (cp && typeof cp.exitCode !== 'number') {
cp.kill('SIGKILL');
await once(cp, 'exit');
}
});
3 changes: 3 additions & 0 deletions packages/create-toolpad-app/tests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
6 changes: 6 additions & 0 deletions packages/create-toolpad-app/tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "ESNext"
}
}
4 changes: 2 additions & 2 deletions packages/create-toolpad-app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"moduleResolution": "node16",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["esnext"],
"isolatedModules": true,
"strict": true,
Expand Down
3 changes: 2 additions & 1 deletion packages/create-toolpad-app/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['./src/index.ts'],
silent: true,
noExternal: ['chalk', 'execa'],
noExternal: ['chalk', 'execa', 'inquirer'],
clean: true,
format: 'cjs',
async onSuccess() {
// eslint-disable-next-line no-console
console.log('cli: build successful');
Expand Down

0 comments on commit 4c34dd5

Please sign in to comment.