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

feat(create-jest): Add npm init / yarn create initialiser #14453

Merged
merged 28 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
30e957f
feat(create-jest): Add `npm init` / `yarn create` initialiser
dj-stormtrooper Aug 27, 2023
5b580b1
fix: Updated `yarn.lock`
dj-stormtrooper Aug 27, 2023
e804edb
feat: Add typescript API
dj-stormtrooper Aug 27, 2023
546db25
fix: Prettier error
dj-stormtrooper Aug 27, 2023
e503b28
Merge branch 'main' of github.com:dj-stormtrooper/jest into feat/crea…
dj-stormtrooper Aug 30, 2023
0ffab46
feat: Move config initialisation logic to `create-jest` package
dj-stormtrooper Aug 30, 2023
df988bf
fix: Eslint errors
dj-stormtrooper Aug 30, 2023
92db787
feat: Add `rootDir` to `create-jest` CLI API
dj-stormtrooper Aug 30, 2023
66384ac
fix: Typesafe values operators for `create-jest` package
dj-stormtrooper Aug 30, 2023
e8d2043
fix: Extra diff
dj-stormtrooper Aug 30, 2023
d160788
fix: Remove redundant file
dj-stormtrooper Aug 30, 2023
c9b4ecb
Update packages/create-jest/src/runCreate.ts
dj-stormtrooper Aug 31, 2023
b126c72
fix: Shortcut for nullable values check
dj-stormtrooper Aug 31, 2023
7b366be
chore: Add `create-jest` to typecheck tests configuration
dj-stormtrooper Aug 31, 2023
0e9aed8
fix: Remove unnecessary type declaration
dj-stormtrooper Aug 31, 2023
48b3d1d
fix: Remove unnecessary `local` check
dj-stormtrooper Aug 31, 2023
bbae051
fix: Remove `bin` from exports
dj-stormtrooper Aug 31, 2023
0b4ea0f
fix: Remove extra CLI interface
dj-stormtrooper Aug 31, 2023
8fe5b8d
fix: Restore `bin` export
dj-stormtrooper Aug 31, 2023
810fec8
Merge branch 'feat/create-jest-package' of github.com:dj-stormtrooper…
dj-stormtrooper Aug 31, 2023
513ad24
fix: New package name in error message
dj-stormtrooper Sep 2, 2023
dedea5f
docs: Add `create-jest` usage
dj-stormtrooper Sep 2, 2023
8ae6c55
fix: More generic error message for malformed json in `create-jest`
dj-stormtrooper Sep 2, 2023
8ca2c6a
Merge branch 'main' into feat/create-jest-package
dj-stormtrooper Sep 7, 2023
77adb36
docs: Add `create-jest` to the changelog
dj-stormtrooper Sep 7, 2023
0dcf317
Update packages/create-jest/src/generateConfigFile.ts
SimenB Sep 7, 2023
cc0e430
purdy
SimenB Sep 7, 2023
9e3ddfa
strict
SimenB Sep 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ module.exports = {
files: [
'scripts/*',
'packages/*/__benchmarks__/test.js',
'packages/jest-cli/src/init/index.ts',
'packages/create-jest/src/runCreate.ts',
'packages/jest-repl/src/cli/runtime-cli.ts',
],
rules: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test('inline snapshots', () => expect({apple: "original value"}).toMatchInlineSnapshot());

Check failure on line 1 in e2e/to-match-inline-snapshot/__tests__/basic-support.test.js

View workflow job for this annotation

GitHub Actions / Lint

Replace `·expect({apple:·"original·value"}).toMatchInlineSnapshot());` with `⏎··expect({apple:·'original·value'}).toMatchInlineSnapshot());⏎`

Check failure on line 1 in e2e/to-match-inline-snapshot/__tests__/basic-support.test.js

View workflow job for this annotation

GitHub Actions / Lint

Strings must use singlequote
8 changes: 8 additions & 0 deletions packages/create-jest/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**/__mocks__/**
**/__tests__/**
__typetests__
src
tsconfig.json
tsconfig.tsbuildinfo
api-extractor.json
.eslintcache
11 changes: 11 additions & 0 deletions packages/create-jest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# create-jest

> Getting started with Jest with a single command

```bash
npm init jest@latest
# Or for Yarn
yarn create jest
# Or for pnpm
pnpm dlx create-jest
```
13 changes: 13 additions & 0 deletions packages/create-jest/bin/create-jest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env node
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

const importLocal = require('import-local');
Copy link
Contributor

Choose a reason for hiding this comment

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

Hm.. import-local is not included in dependencies list?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also not so sure if import-local is needed in this case. It does not make sense installing create-jest locally. To be honest, I don’t understand what import-local does ;D So it can be I missed something.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm ok with removing that, local installation indeed seems strange in this case


if (!importLocal(__filename)) {
require('..').runCLI();
}
45 changes: 45 additions & 0 deletions packages/create-jest/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "create-jest",
"description": "Create a new Jest project",
"version": "29.6.4",
"repository": {
"type": "git",
"url": "https://github.com/jestjs/jest.git",
"directory": "packages/create-jest"
},
"license": "MIT",
"bin": "./bin/create-jest.js",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
},
"./package.json": "./package.json",
"./bin/create-jest": "./bin/create-jest.js"
SimenB marked this conversation as resolved.
Show resolved Hide resolved
},
"dependencies": {
"@jest/core": "workspace:^",
"@jest/types": "workspace:^",
"chalk": "^4.0.0",
"exit": "^0.1.2",
"graceful-fs": "^4.2.9",
"jest-config": "workspace:^",
"jest-util": "workspace:^",
"prompts": "^2.0.1",
"yargs": "^17.3.1"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/exit": "^0.1.30",
"@types/graceful-fs": "^4.1.3",
"@types/prompts": "^2.0.1",
"@types/yargs": "^17.0.8"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as path from 'path';
import {writeFileSync} from 'graceful-fs';
import * as prompts from 'prompts';
import {constants} from 'jest-config';
import init from '../';
import {runCreate} from '../runCreate';

const {JEST_CONFIG_EXT_ORDER} = constants;

Expand Down Expand Up @@ -44,7 +44,7 @@ describe('init', () => {
it('should return the default configuration (an empty config)', async () => {
jest.mocked(prompts).mockResolvedValueOnce({});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfigFilename =
jest.mocked(writeFileSync).mock.calls[0][0];
Expand All @@ -71,7 +71,7 @@ describe('init', () => {
it('should generate empty config with mjs extension', async () => {
jest.mocked(prompts).mockResolvedValueOnce({});

await init(resolveFromFixture('type-module'));
await runCreate(resolveFromFixture('type-module'));

const writtenJestConfigFilename =
jest.mocked(writeFileSync).mock.calls[0][0];
Expand All @@ -93,7 +93,7 @@ describe('init', () => {
it('should create configuration for {clearMocks: true}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({clearMocks: true});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfig = jest.mocked(writeFileSync).mock.calls[0][1];
const evaluatedConfig = eval(writtenJestConfig as string) as Record<
Expand All @@ -107,7 +107,7 @@ describe('init', () => {
it('should create configuration for {coverage: true}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({coverage: true});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfig = jest.mocked(writeFileSync).mock.calls[0][1];
const evaluatedConfig = eval(writtenJestConfig as string) as Record<
Expand All @@ -124,7 +124,7 @@ describe('init', () => {
it('should create configuration for {coverageProvider: "babel"}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({coverageProvider: 'babel'});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfig = jest.mocked(writeFileSync).mock.calls[0][1];
const evaluatedConfig = eval(writtenJestConfig as string) as Record<
Expand All @@ -138,7 +138,7 @@ describe('init', () => {
it('should create configuration for {coverageProvider: "v8"}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({coverageProvider: 'v8'});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfig = jest.mocked(writeFileSync).mock.calls[0][1];
const evaluatedConfig = eval(writtenJestConfig as string) as Record<
Expand All @@ -152,7 +152,7 @@ describe('init', () => {
it('should create configuration for {environment: "jsdom"}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({environment: 'jsdom'});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfig = jest.mocked(writeFileSync).mock.calls[0][1];
const evaluatedConfig = eval(writtenJestConfig as string) as Record<
Expand All @@ -165,7 +165,7 @@ describe('init', () => {
it('should create configuration for {environment: "node"}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({environment: 'node'});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenJestConfig = jest.mocked(writeFileSync).mock.calls[0][1];
const evaluatedConfig = eval(writtenJestConfig as string) as Record<
Expand All @@ -178,7 +178,7 @@ describe('init', () => {
it('should create package.json with configured test command when {scripts: true}', async () => {
jest.mocked(prompts).mockResolvedValueOnce({scripts: true});

await init(resolveFromFixture('only-package-json'));
await runCreate(resolveFromFixture('only-package-json'));

const writtenPackageJson = jest.mocked(writeFileSync).mock.calls[0][1];
const parsedPackageJson = JSON.parse(writtenPackageJson as string) as {
Expand All @@ -196,7 +196,7 @@ describe('init', () => {
expect.assertions(1);

try {
await init(resolveFromFixture('no-package-json'));
await runCreate(resolveFromFixture('no-package-json'));
} catch (error) {
expect((error as Error).message).toMatch(
'Could not find a "package.json" file in',
Expand All @@ -215,7 +215,9 @@ describe('init', () => {
.mockResolvedValueOnce({continue: true})
.mockResolvedValueOnce({});

await init(resolveFromFixture(`has-jest-config-file-${extension}`));
await runCreate(
resolveFromFixture(`has-jest-config-file-${extension}`),
);

expect(jest.mocked(prompts).mock.calls[0][0]).toMatchSnapshot();

Expand All @@ -230,7 +232,9 @@ describe('init', () => {
it('user answered with "No"', async () => {
jest.mocked(prompts).mockResolvedValueOnce({continue: false});

await init(resolveFromFixture(`has-jest-config-file-${extension}`));
await runCreate(
resolveFromFixture(`has-jest-config-file-${extension}`),
);
// return after first prompt
expect(prompts).toHaveBeenCalledTimes(1);
});
Expand All @@ -243,7 +247,7 @@ describe('init', () => {
it('user answered with "Yes"', async () => {
jest.mocked(prompts).mockResolvedValueOnce({useTypescript: true});

await init(resolveFromFixture('test-generated-jest-config-ts'));
await runCreate(resolveFromFixture('test-generated-jest-config-ts'));

expect(jest.mocked(prompts).mock.calls[0][0]).toMatchSnapshot();

Expand All @@ -264,7 +268,7 @@ describe('init', () => {
it('user answered with "No"', async () => {
jest.mocked(prompts).mockResolvedValueOnce({useTypescript: false});

await init(resolveFromFixture('test-generated-jest-config-ts'));
await runCreate(resolveFromFixture('test-generated-jest-config-ts'));

const jestConfigFileName = jest.mocked(writeFileSync).mock.calls[0][0];

Expand All @@ -282,7 +286,7 @@ describe('init', () => {
.mockResolvedValueOnce({continue: true})
.mockResolvedValueOnce({});

await init(resolveFromFixture('has-jest-config-in-package-json'));
await runCreate(resolveFromFixture('has-jest-config-in-package-json'));

expect(jest.mocked(prompts).mock.calls[0][0]).toMatchSnapshot();

Expand All @@ -296,7 +300,7 @@ describe('init', () => {
it('should not ask "test script question"', async () => {
jest.mocked(prompts).mockResolvedValueOnce({});

await init(resolveFromFixture('test-script-configured'));
await runCreate(resolveFromFixture('test-script-configured'));

const questions = jest.mocked(prompts).mock.calls[0][0] as Array<
prompts.PromptObject<string>
Expand Down
5 changes: 5 additions & 0 deletions packages/create-jest/src/__tests__/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../../../tsconfig.test.json",
"include": ["./**/*"],
"references": [{"path": "../../"}]
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@

import type {Config} from '@jest/types';
import {defaults, descriptions} from 'jest-config';
import type {PromptsResults} from './types';

const stringifyOption = (
option: keyof Config.InitialOptions,
map: Partial<Config.InitialOptions>,
linePrefix = '',
): string => {
const optionDescription = ` // ${descriptions[option]}`;
const optionDescription = ` // ${descriptions[option] ?? ''}`;
SimenB marked this conversation as resolved.
Show resolved Hide resolved
const stringifiedObject = `${option}: ${JSON.stringify(
map[option],
null,
Expand All @@ -27,7 +28,7 @@ const stringifyOption = (
};

const generateConfigFile = (
results: Record<string, unknown>,
results: PromptsResults,
generateEsm = false,
): string => {
const {useTypescript, coverage, coverageProvider, clearMocks, environment} =
Expand Down
8 changes: 8 additions & 0 deletions packages/create-jest/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

export {runCreate, runCLI} from './runCreate';
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@

import * as path from 'path';
import chalk = require('chalk');
import exit = require('exit');
import * as fs from 'graceful-fs';
import prompts = require('prompts');
import yargs = require('yargs');
Copy link
Contributor

Choose a reason for hiding this comment

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

Just thinking out loud: it feels too much to ship yargs with this package. I think process.argv[2] would be to do the job. The value will be always string | undefined.

Currently this package is 15.5 kB (unpacked). yargs takes 292 kB (unpacked, without dependencies). That’s what npm gave to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds fair, I think keeping this package light is more important than having universal parsing for arguments and --help interface

import {getVersion} from '@jest/core';
Copy link
Contributor

Choose a reason for hiding this comment

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

Importing @jest/core will ship almost the whole Jest with create-jest. Not sure if that is worth.

import {constants} from 'jest-config';
import {tryRealpath} from 'jest-util';
import {clearLine, tryRealpath} from 'jest-util';
import {MalformedPackageJsonError, NotFoundPackageJsonError} from './errors';
import generateConfigFile from './generateConfigFile';
import modifyPackageJson from './modifyPackageJson';
Expand All @@ -28,9 +31,36 @@ const {

const getConfigFilename = (ext: string) => JEST_CONFIG_BASE_NAME + ext;

export default async function init(
rootDir: string = tryRealpath(process.cwd()),
export async function runCLI(): Promise<void> {
try {
const argv = await yargs(process.argv.slice(2))
.usage('Usage: $0 [rootDir]')
.version(getVersion())
Copy link
Contributor

Choose a reason for hiding this comment

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

If it is decided to keep yargs, this should be create-jest version. It will be used by create-jest --version, or?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed yargs to keep package lighter

.alias('help', 'h')
.epilogue('Documentation: https://jestjs.io/').argv;

const rootDir =
typeof argv._[0] !== 'undefined' ? String(argv._[0]) : undefined;

await runCreate(rootDir);
} catch (error: unknown) {
clearLine(process.stderr);
clearLine(process.stdout);
if (error instanceof Error && Boolean(error?.stack)) {
console.error(chalk.red(error.stack));
} else {
console.error(chalk.red(error));
}

exit(1);
throw error;
}
}

export async function runCreate(
rootDir: string = process.cwd(),
): Promise<void> {
dj-stormtrooper marked this conversation as resolved.
Show resolved Hide resolved
rootDir = tryRealpath(rootDir);
// prerequisite checks
const projectPackageJsonPath: string = path.join(rootDir, PACKAGE_JSON);
Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting why ESLint did not catch this:

Suggested change
const projectPackageJsonPath: string = path.join(rootDir, PACKAGE_JSON);
const projectPackageJsonPath = path.join(rootDir, PACKAGE_JSON);


Expand All @@ -45,7 +75,7 @@ export default async function init(
try {
projectPackageJson = JSON.parse(
fs.readFileSync(projectPackageJsonPath, 'utf-8'),
);
) as ProjectPackageJson;
} catch {
throw new MalformedPackageJsonError(projectPackageJsonPath);
}
Expand All @@ -58,7 +88,7 @@ export default async function init(
fs.existsSync(path.join(rootDir, getConfigFilename(ext))),
);

if (hasJestProperty || existingJestConfigExt) {
if (hasJestProperty || Boolean(existingJestConfigExt)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (hasJestProperty || Boolean(existingJestConfigExt)) {
if (hasJestProperty || existingJestConfigExt != null) {

const result: {continue: boolean} = await prompts({
initial: true,
message:
Expand Down Expand Up @@ -112,9 +142,10 @@ export default async function init(
: JEST_CONFIG_EXT_JS;

// Determine Jest config path
const jestConfigPath = existingJestConfigExt
? getConfigFilename(existingJestConfigExt)
: path.join(rootDir, getConfigFilename(jestConfigFileExt));
const jestConfigPath =
typeof existingJestConfigExt !== 'undefined'
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
typeof existingJestConfigExt !== 'undefined'
existingJestConfigExt != null

? getConfigFilename(existingJestConfigExt)
: path.join(rootDir, getConfigFilename(jestConfigFileExt));

const shouldModifyScripts = results.scripts;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type PromptsResults = {
useTypescript: boolean;
clearMocks: boolean;
coverage: boolean;
coverageProvider: boolean;
environment: boolean;
coverageProvider: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry for the extra diff, I had to fix tslint errors in this package. jest-cli code was ignored, but new package name ends with jest and it matches the condition in tslint script 😄

I decided to do a good thing and fix the errors instead of excluding the package from the script

environment: string;
scripts: boolean;
};