Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add jest tests for create-toolpad-app #1965

Merged
merged 28 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
653466b
test create-toolpad-app using health-check
bharatkashyap May 3, 2023
964c682
Merge branch 'master' into cta-test
bharatkashyap May 3, 2023
cecde2e
Propose improved tests
Janpot May 4, 2023
0a3dae3
forgiot this
Janpot May 4, 2023
7bc8415
Try running tests separately
Janpot May 4, 2023
116b6dc
path fix
Janpot May 4, 2023
304885f
Rename test, remove the old one
bharatkashyap May 5, 2023
8f10ea3
test create-toolpad-app using health-check
bharatkashyap May 3, 2023
1dec69a
Propose improved tests
Janpot May 4, 2023
7dad262
forgiot this
Janpot May 4, 2023
fc07627
Try running tests separately
Janpot May 4, 2023
b469274
path fix
Janpot May 4, 2023
79ce916
Rename test, remove the old one
bharatkashyap May 5, 2023
8f3132e
Merge branch 'cta-test' of github.com:bharatkashyap/mui-toolpad into …
bharatkashyap May 9, 2023
a2ee8d8
Merge branch 'master' into cta-test
bharatkashyap May 9, 2023
bccad7a
Feat: Start app and run health-check
bharatkashyap May 9, 2023
e8b8eca
Fix: Use `@mui/toolpad-utils` and add `install` op
bharatkashyap May 9, 2023
f7756a9
add check
Janpot May 9, 2023
ac372c7
dynamic import ESM
Janpot May 9, 2023
63320e4
Remove dynamic imports
Janpot May 9, 2023
7275165
try adding a small delay
Janpot May 9, 2023
c818649
Merge branch 'master' into cta-test
bharatkashyap May 10, 2023
c4f0058
trty longer timeout
Janpot May 10, 2023
2207b70
hello
Janpot May 10, 2023
fb914e7
max retries
Janpot May 10, 2023
98e813e
Dispose
Janpot May 10, 2023
8e6c599
Merge branch 'master' into cta-test
bharatkashyap May 11, 2023
902fbf8
Don't dispose?
Janpot May 11, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
}
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weren't you going to try running the app in this test as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm currently on that (also adding the no-install option to make running this test quicker)


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