diff --git a/docs/getting-started.md b/docs/getting-started.md index 50bf4445ef1ac..f53ddc533cc6f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,7 +19,12 @@ If you have questions about anything related to Next.js, you're always welcome t ## Automatic Setup -We recommend creating a new Next.js app using `create-next-app`, which sets up everything automatically for you. To create a project, run: +We recommend creating a new Next.js app using `create-next-app`, which sets up +everything automatically for you. + +### Interactive + +You can create a new project interactively by running: ```bash npx create-next-app@latest @@ -29,14 +34,63 @@ yarn create next-app pnpm create next-app ``` -If you want to start with a TypeScript project you can use the `--typescript` flag: +You will be asked for the name of your project, and then whether you want to +create a TypeScript project: + +``` +✔ Would you like to use TypeScript with this project? … No / Yes +``` + +Select **Yes** to install the necessary types/dependencies and create a new TS project. + +### Non-interactive + +You can also pass command line arguments to set up a new project +non-interactively. See `create-next-app --help`: + +``` +create-next-app [options] + +Options: + -V, --version output the version number + --ts, --typescript + + Initialize as a TypeScript project. (default) + + --js, --javascript + + Initialize as a JavaScript project. + + --use-npm + + Explicitly tell the CLI to bootstrap the app using npm + + --use-pnpm + + Explicitly tell the CLI to bootstrap the app using pnpm + + -e, --example [name]|[github-url] + + An example to bootstrap the app with. You can use an example name + from the official Next.js repo or a GitHub URL. The URL can use + any branch and/or subdirectory + + --example-path + + In a rare case, your GitHub URL might contain a branch name with + a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar). + In this case, you must specify the path to the example separately: + --example-path foo/bar +``` + +#### Examples ```bash -npx create-next-app@latest --typescript +npx create-next-app@latest --ts my-project # or -yarn create next-app --typescript +yarn create next-app --js my-project # or -pnpm create next-app --typescript +pnpm create next-app --javascript my-project ``` After the installation is complete: diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index 13fffa432cb95..b53e0cebc511a 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -23,7 +23,14 @@ const program = new Commander.Command(packageJson.name) '--ts, --typescript', ` - Initialize as a TypeScript project. + Initialize as a TypeScript project. (default) +` + ) + .option( + '--js, --javascript', + ` + + Initialize as a JavaScript project. ` ) .option( @@ -136,6 +143,44 @@ async function run(): Promise { } const example = typeof program.example === 'string' && program.example.trim() + + /** + * If the user does not provide the necessary flags, prompt them for whether + * to use TS or JS. + * + * @todo Allow appDir to support TS or JS, currently TS-only and disables all + * --ts, --js features. + */ + if (!program.experimentalApp && !program.typescript && !program.javascript) { + const styledTypeScript = chalk.hex('#007acc')('TypeScript') + const { typescript } = await prompts( + { + type: 'toggle', + name: 'typescript', + message: `Would you like to use ${styledTypeScript} with this project?`, + initial: true, + active: 'Yes', + inactive: 'No', + }, + { + /** + * User inputs Ctrl+C or Ctrl+D to exit the prompt. We should close the + * process and not write to the file system. + */ + onCancel: () => { + console.error('Exiting.') + process.exit(1) + }, + } + ) + + /** + * Depending on the prompt response, set the appropriate program flags. + */ + program.typescript = Boolean(typescript) + program.javascript = !Boolean(typescript) + } + try { await createApp({ appPath: resolvedProjectPath, diff --git a/test/integration/create-next-app/index.test.ts b/test/integration/create-next-app/index.test.ts index 3b56e3864aa7f..1982f008777b5 100644 --- a/test/integration/create-next-app/index.test.ts +++ b/test/integration/create-next-app/index.test.ts @@ -1,36 +1,42 @@ /* eslint-env jest */ +/** + * @fileoverview + * + * This file contains integration tests for `create-next-app`. It currently + * aliases all calls to `--js`. + * + * TypeScript project creation via `create-next-app --ts` is tested in + * `./templates.test.ts`, though additional tests can be added here using the + * `shouldBeTypescriptProject` helper. + */ + import execa from 'execa' import fs from 'fs-extra' -import os from 'os' import path from 'path' -const cli = require.resolve('create-next-app/dist/index.js') +import { useTempDir } from '../../lib/use-temp-dir' +import { + projectFilesShouldExist, + projectFilesShouldNotExist, + shouldBeJavascriptProject, +} from './lib/utils' +const cli = require.resolve('create-next-app/dist/index.js') const exampleRepo = 'https://github.com/vercel/next.js/tree/canary' const examplePath = 'examples/basic-css' const run = (args: string[], options: execa.Options) => execa('node', [cli].concat(args), options) -async function usingTempDir(fn: (...args: any[]) => any, options?: any) { - const folder = path.join(os.tmpdir(), Math.random().toString(36).substring(2)) - await fs.mkdirp(folder, options) - try { - await fn(folder) - } finally { - await fs.remove(folder) - } -} - describe('create next app', () => { it('non-empty directory', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'non-empty-directory' await fs.mkdirp(path.join(cwd, projectName)) const pkg = path.join(cwd, projectName, 'package.json') fs.writeFileSync(pkg, '{ "foo": "bar" }') - const res = await run([projectName], { cwd, reject: false }) + const res = await run([projectName, '--js'], { cwd, reject: false }) expect(res.exitCode).toBe(1) expect(res.stdout).toMatch(/contains files that could conflict/) }) @@ -40,158 +46,106 @@ describe('create next app', () => { // stdin is piped instead of inherited on windows if (process.platform !== 'win32') { it('empty directory', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'empty-directory' - const res = await run([projectName], { cwd }) + const res = await run([projectName, '--js'], { cwd }) expect(res.exitCode).toBe(0) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.js')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, '.eslintrc.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) + shouldBeJavascriptProject({ cwd, projectName }) }) }) } it('invalid example name', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'invalid-example-name' - const res = await run([projectName, '--example', 'not a real example'], { - cwd, - reject: false, - }) + const res = await run( + [projectName, '--js', '--example', 'not a real example'], + { + cwd, + reject: false, + } + ) expect(res.exitCode).toBe(1) - expect(res.stderr).toMatch(/Could not locate an example named/i) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeFalsy() + projectFilesShouldNotExist({ + cwd, + projectName, + files: ['package.json'], + }) }) }) it('valid example', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'valid-example' - const res = await run([projectName, '--example', 'basic-css'], { cwd }) - expect(res.exitCode).toBe(0) - - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.tsx')) - ).toBeTruthy() - // check we copied default `.gitignore` - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) - }) - }) - - it('valid example without package.json', async () => { - await usingTempDir(async (cwd) => { - const projectName = 'valid-example-without-package-json' - const res = await run([projectName, '--example', 'with-docker-compose'], { + const res = await run([projectName, '--js', '--example', 'basic-css'], { cwd, }) expect(res.exitCode).toBe(0) - - expect( - fs.existsSync(path.join(cwd, projectName, '.dockerignore')) - ).toBeTruthy() - // check we copied default `.gitignore` - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.tsx', + '.gitignore', + 'node_modules/next', + ], + }) }) }) - it('should support typescript flag', async () => { - await usingTempDir(async (cwd) => { - const projectName = 'typescript' - const res = await run([projectName, '--typescript'], { cwd }) - expect(res.exitCode).toBe(0) - - const files = [ - 'package.json', - 'pages/index.tsx', - 'pages/_app.tsx', - 'pages/api/hello.ts', - 'tsconfig.json', - 'next-env.d.ts', - '.eslintrc.json', - 'node_modules/next', - // check we copied default `.gitignore` - '.gitignore', - ] - - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() + it('valid example without package.json', async () => { + await useTempDir(async (cwd) => { + const projectName = 'valid-example-without-package-json' + const res = await run( + [projectName, '--js', '--example', 'with-docker-compose'], + { + cwd, + } ) - const pkgJSONPath = path.join(cwd, projectName, 'package.json') - - // Assert for dependencies specific to the typescript template - const pkgJSON = require(pkgJSONPath) - expect(Object.keys(pkgJSON.dependencies)).toEqual([ - 'next', - 'react', - 'react-dom', - ]) - expect(Object.keys(pkgJSON.devDependencies)).toEqual([ - '@types/node', - '@types/react', - '@types/react-dom', - 'eslint', - 'eslint-config-next', - 'typescript', - ]) + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ + cwd, + projectName, + files: ['.dockerignore', '.gitignore'], + }) }) }) it('should allow example with GitHub URL', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'github-app' const res = await run( - [projectName, '--example', `${exampleRepo}/${examplePath}`], + [projectName, '--js', '--example', `${exampleRepo}/${examplePath}`], { cwd, } ) expect(res.exitCode).toBe(0) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.tsx')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.tsx', + '.gitignore', + 'node_modules/next', + ], + }) }) }) it('should allow example with GitHub URL with trailing slash', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'github-app' const res = await run( [ projectName, + '--js', '--example', 'https://github.com/vercel/nextjs-portfolio-starter/', ], @@ -201,53 +155,57 @@ describe('create next app', () => { ) expect(res.exitCode).toBe(0) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.mdx')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.mdx', + '.gitignore', + 'node_modules/next', + ], + }) }) }) it('should allow example with GitHub URL and example-path', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'github-example-path' const res = await run( - [projectName, '--example', exampleRepo, '--example-path', examplePath], + [ + projectName, + '--js', + '--example', + exampleRepo, + '--example-path', + examplePath, + ], { cwd, } ) expect(res.exitCode).toBe(0) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.tsx')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.tsx', + '.gitignore', + 'node_modules/react', + ], + }) }) }) it('should use --example-path over the file path in the GitHub URL', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'github-example-path-2' const res = await run( [ projectName, + '--js', '--example', `${exampleRepo}/${examplePath}`, '--example-path', @@ -259,18 +217,16 @@ describe('create next app', () => { ) expect(res.exitCode).toBe(0) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.tsx')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.tsx', + '.gitignore', + 'node_modules/react', + ], + }) }) }) @@ -278,72 +234,58 @@ describe('create next app', () => { // stdin is piped instead of inherited on windows if (process.platform !== 'win32') { it('should fall back to default template', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'fail-example' const res = await run( - [projectName, '--example', '__internal-testing-retry'], + [projectName, '--js', '--example', '__internal-testing-retry'], { cwd, input: '\n', } ) - expect(res.exitCode).toBe(0) - const files = [ - 'package.json', - 'pages/index.js', - '.gitignore', - '.eslintrc.json', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + expect(res.exitCode).toBe(0) + shouldBeJavascriptProject({ cwd, projectName }) }) }) } it('should allow an example named default', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'default-example' - const res = await run([projectName, '--example', 'default'], { cwd }) - expect(res.exitCode).toBe(0) + const res = await run([projectName, '--js', '--example', 'default'], { + cwd, + }) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'pages/index.js')) - ).toBeTruthy() - // check we copied default `.gitignore` - expect( - fs.existsSync(path.join(cwd, projectName, '.gitignore')) - ).toBeTruthy() - expect( - fs.existsSync(path.join(cwd, projectName, 'node_modules/next')) - ).toBe(true) + expect(res.exitCode).toBe(0) + shouldBeJavascriptProject({ cwd, projectName }) }) }) it('should exit if example flag is empty', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'no-example-provided' - const res = await run([projectName, '--example'], { cwd, reject: false }) + const res = await run([projectName, '--js', '--example'], { + cwd, + reject: false, + }) + expect(res.exitCode).toBe(1) }) }) it('should exit if the folder is not writable', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'not-writable' - const res = await run([projectName], { cwd, reject: false }) + const res = await run([projectName, '--js'], { cwd, reject: false }) if (process.platform === 'win32') { expect(res.exitCode).toBe(0) - expect( - fs.existsSync(path.join(cwd, projectName, 'package.json')) - ).toBeTruthy() + const files = ['package.json'] + projectFilesShouldExist({ cwd, projectName, files }) return } + expect(res.exitCode).toBe(1) expect(res.stderr).toMatch( /you do not have write permissions for this folder/ @@ -352,7 +294,7 @@ describe('create next app', () => { }) it('should create a project in the current directory', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const env = { ...process.env } const tmpBin = path.join(__dirname, 'bin') const tmpYarn = path.join(tmpBin, 'yarn') @@ -369,7 +311,7 @@ describe('create next app', () => { delete env.npm_config_user_agent } - const res = await run(['.'], { + const res = await run(['.', '--js'], { cwd, env, extendEnv: false, @@ -378,103 +320,77 @@ describe('create next app', () => { await fs.remove(tmpBin) expect(res.exitCode).toBe(0) - - const files = [ - 'package.json', - 'pages/index.js', - '.gitignore', - 'node_modules/next', - '.eslintrc.json', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, file))).toBeTruthy() - ) + shouldBeJavascriptProject({ cwd, projectName: '.' }) }) }) it('should ask the user for a name for the project if none supplied', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'test-project' - const res = await run([], { cwd, input: `${projectName}\n` }) - expect(res.exitCode).toBe(0) + const res = await run(['--js'], { cwd, input: `${projectName}\n` }) - const files = [ - 'package.json', - 'pages/index.js', - '.gitignore', - 'node_modules/next', - '.eslintrc.json', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + expect(res.exitCode).toBe(0) + shouldBeJavascriptProject({ cwd, projectName }) }) }) it('should use npm as the package manager on supplying --use-npm', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'use-npm' - const res = await run([projectName, '--use-npm'], { cwd }) - expect(res.exitCode).toBe(0) + const res = await run([projectName, '--js', '--use-npm'], { cwd }) - const files = [ - 'package.json', - 'pages/index.js', - '.gitignore', - '.eslintrc.json', - 'package-lock.json', - 'node_modules/next', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + expect(res.exitCode).toBe(0) + shouldBeJavascriptProject({ cwd, projectName }) }) }) it('should use npm as the package manager on supplying --use-npm with example', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'use-npm' const res = await run( [ projectName, + '--js', '--use-npm', '--example', `${exampleRepo}/${examplePath}`, ], { cwd } ) - expect(res.exitCode).toBe(0) - const files = [ - 'package.json', - 'pages/index.tsx', - '.gitignore', - 'package-lock.json', - 'node_modules/next', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.tsx', + '.gitignore', + 'package-lock.json', + 'node_modules/next', + ], + }) }) }) it('should use pnpm as the package manager on supplying --use-pnpm', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'use-pnpm' - const res = await run([projectName, '--use-pnpm'], { cwd }) - expect(res.exitCode).toBe(0) + const res = await run([projectName, '--js', '--use-pnpm'], { cwd }) - const files = [ - 'package.json', - 'pages/index.js', - '.gitignore', - '.eslintrc.json', - 'pnpm-lock.yaml', - 'node_modules/next', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.js', + '.gitignore', + '.eslintrc.json', + 'pnpm-lock.yaml', + 'node_modules/next', + ], + }) }) }) @@ -486,40 +402,41 @@ describe('create next app', () => { await execa('npm', ['i', '-g', 'pnpm']) } - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'use-pnpm' const res = await run( [ projectName, + '--js', '--use-pnpm', '--example', `${exampleRepo}/${examplePath}`, ], { cwd } ) - expect(res.exitCode).toBe(0) - const files = [ - 'package.json', - 'pages/index.tsx', - '.gitignore', - 'pnpm-lock.yaml', - 'node_modules/next', - ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ + cwd, + projectName, + files: [ + 'package.json', + 'pages/index.tsx', + '.gitignore', + 'pnpm-lock.yaml', + 'node_modules/next', + ], + }) }) }) it('should infer npm as the package manager', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'infer-package-manager-npm' - const res = await run([projectName], { + const res = await run([projectName, '--js'], { cwd, env: { ...process.env, npm_config_user_agent: 'npm' }, }) - expect(res.exitCode).toBe(0) const files = [ 'package.json', @@ -529,20 +446,19 @@ describe('create next app', () => { 'package-lock.json', 'node_modules/next', ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ cwd, projectName, files }) }) }) it('should infer npm as the package manager with example', async () => { - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'infer-package-manager-npm' const res = await run( - [projectName, '--example', `${exampleRepo}/${examplePath}`], + [projectName, '--js', '--example', `${exampleRepo}/${examplePath}`], { cwd, env: { ...process.env, npm_config_user_agent: 'npm' } } ) - expect(res.exitCode).toBe(0) const files = [ 'package.json', @@ -551,9 +467,9 @@ describe('create next app', () => { 'package-lock.json', 'node_modules/next', ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ cwd, projectName, files }) }) }) @@ -565,13 +481,12 @@ describe('create next app', () => { await execa('npm', ['i', '-g', 'yarn']) } - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'infer-package-manager-yarn' - const res = await run([projectName], { + const res = await run([projectName, '--js'], { cwd, env: { ...process.env, npm_config_user_agent: 'yarn' }, }) - expect(res.exitCode).toBe(0) const files = [ 'package.json', @@ -581,9 +496,9 @@ describe('create next app', () => { 'yarn.lock', 'node_modules/next', ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ cwd, projectName, files }) }) }) @@ -595,13 +510,12 @@ describe('create next app', () => { await execa('npm', ['i', '-g', 'yarn']) } - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'infer-package-manager-npm' const res = await run( - [projectName, '--example', `${exampleRepo}/${examplePath}`], + [projectName, '--js', '--example', `${exampleRepo}/${examplePath}`], { cwd, env: { ...process.env, npm_config_user_agent: 'yarn' } } ) - expect(res.exitCode).toBe(0) const files = [ 'package.json', @@ -610,9 +524,9 @@ describe('create next app', () => { 'yarn.lock', 'node_modules/next', ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ cwd, projectName, files }) }) }) @@ -624,13 +538,12 @@ describe('create next app', () => { await execa('npm', ['i', '-g', 'pnpm']) } - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'infer-package-manager' - const res = await run([projectName], { + const res = await run([projectName, '--js'], { cwd, env: { ...process.env, npm_config_user_agent: 'pnpm' }, }) - expect(res.exitCode).toBe(0) const files = [ 'package.json', @@ -640,9 +553,9 @@ describe('create next app', () => { 'pnpm-lock.yaml', 'node_modules/next', ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ cwd, projectName, files }) }) }) }) @@ -655,13 +568,12 @@ it('should infer pnpm as the package manager with example', async () => { await execa('npm', ['i', '-g', 'pnpm']) } - await usingTempDir(async (cwd) => { + await useTempDir(async (cwd) => { const projectName = 'infer-package-manager-npm' const res = await run( - [projectName, '--example', `${exampleRepo}/${examplePath}`], + [projectName, '--js', '--example', `${exampleRepo}/${examplePath}`], { cwd, env: { ...process.env, npm_config_user_agent: 'pnpm' } } ) - expect(res.exitCode).toBe(0) const files = [ 'package.json', @@ -670,8 +582,8 @@ it('should infer pnpm as the package manager with example', async () => { 'pnpm-lock.yaml', 'node_modules/next', ] - files.forEach((file) => - expect(fs.existsSync(path.join(cwd, projectName, file))).toBeTruthy() - ) + + expect(res.exitCode).toBe(0) + projectFilesShouldExist({ cwd, projectName, files }) }) }) diff --git a/test/integration/create-next-app/lib/projectFiles.ts b/test/integration/create-next-app/lib/projectFiles.ts new file mode 100644 index 0000000000000..9aca2462c188b --- /dev/null +++ b/test/integration/create-next-app/lib/projectFiles.ts @@ -0,0 +1,47 @@ +/** + * Required files for a given project template. + */ +export const projectFiles = { + /** + * Files common to all Next.js templates. + */ + global: ['package.json', '.eslintrc.json', 'node_modules/next', '.gitignore'], + /** + * Files specific to Next.js JS-only templates. + */ + js: ['pages/index.js', 'pages/_app.js', 'pages/api/hello.js'], + /** + * Files specific to Next.js TypeScript-only templates. + */ + ts: [ + 'pages/index.tsx', + 'pages/_app.tsx', + 'pages/api/hello.ts', + 'tsconfig.json', + 'next-env.d.ts', + ], + app: [ + 'app/page.tsx', + 'app/layout.tsx', + 'pages/api/hello.ts', + 'tsconfig.json', + 'next-env.d.ts', + ], +} + +export const projectDeps = { + js: ['next', 'react', 'react-dom'], + ts: ['next', 'react', 'react-dom'], +} + +export const projectDevDeps = { + js: ['eslint', 'eslint-config-next'], + ts: [ + '@types/node', + '@types/react', + '@types/react-dom', + 'eslint', + 'eslint-config-next', + 'typescript', + ], +} diff --git a/test/integration/create-next-app/lib/utils.ts b/test/integration/create-next-app/lib/utils.ts new file mode 100644 index 0000000000000..6933ab57cec3b --- /dev/null +++ b/test/integration/create-next-app/lib/utils.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview + * + * This file contains utilities for `create-next-app` testing. + */ + +import { ChildProcess, spawn, SpawnOptions } from 'child_process' +import { existsSync } from 'fs' +import { resolve } from 'path' +import { projectFiles, projectDeps, projectDevDeps } from './projectFiles' + +interface ProjectOptions { + cwd: string + projectName: string +} + +interface ProjectFiles extends ProjectOptions { + files: string[] +} + +interface ProjectDeps extends ProjectOptions { + type: 'dependencies' | 'devDependencies' + deps: string[] +} + +const cli = require.resolve('create-next-app/dist/index.js') + +/** + * Run the built version of `create-next-app` with the given arguments. + */ +export const createNextApp = (args: string[], options?: SpawnOptions) => { + console.log(`[TEST] $ ${cli} ${args.join(' ')}`, { options }) + return spawn('node', [cli].concat(args), options ?? {}) +} + +/** + * Return a Promise that resolves when the process exits with code 0 and rejects + * otherwise. + */ +export const spawnExitPromise = (childProcess: ChildProcess) => { + return new Promise((resolve, reject) => { + childProcess + .on('exit', (code) => { + if (code === 0) { + resolve(code) + } else { + reject(code) + } + }) + .on('error', reject) + }) +} + +export const projectFilesShouldExist = ({ + cwd, + projectName, + files, +}: ProjectFiles) => { + const projectRoot = resolve(cwd, projectName) + for (const file of files) { + expect(existsSync(resolve(projectRoot, file))).toBe(true) + } +} + +export const projectFilesShouldNotExist = ({ + cwd, + projectName, + files, +}: ProjectFiles) => { + const projectRoot = resolve(cwd, projectName) + for (const file of files) { + expect(existsSync(resolve(projectRoot, file))).toBe(false) + } +} + +export const projectDepsShouldBe = ({ + cwd, + projectName, + type, + deps, +}: ProjectDeps) => { + const projectRoot = resolve(cwd, projectName) + const pkgJson = require(resolve(projectRoot, 'package.json')) + expect(Object.keys(pkgJson[type])).toEqual(deps) +} + +export const shouldBeJavascriptProject = ({ + cwd, + projectName, +}: ProjectOptions) => { + projectFilesShouldExist({ + cwd, + projectName, + files: [...projectFiles.global, ...projectFiles.js], + }) + + projectFilesShouldNotExist({ + cwd, + projectName, + files: projectFiles.ts, + }) + + projectDepsShouldBe({ + cwd, + projectName, + type: 'dependencies', + deps: projectDeps.js, + }) + + projectDepsShouldBe({ + cwd, + projectName, + type: 'devDependencies', + deps: projectDevDeps.js, + }) +} + +export const shouldBeTypescriptProject = ({ + cwd, + projectName, +}: ProjectOptions) => { + projectFilesShouldExist({ + cwd, + projectName, + files: [...projectFiles.global, ...projectFiles.ts], + }) + + projectFilesShouldNotExist({ + cwd, + projectName, + files: projectFiles.js, + }) + + projectDepsShouldBe({ + type: 'dependencies', + cwd, + projectName, + deps: projectDeps.ts, + }) + + projectDepsShouldBe({ + type: 'devDependencies', + cwd, + projectName, + deps: projectDevDeps.ts, + }) +} + +export const shouldBeAppProject = ({ cwd, projectName }: ProjectOptions) => { + projectFilesShouldExist({ + cwd, + projectName, + files: [...projectFiles.global, ...projectFiles.app], + }) + + projectFilesShouldNotExist({ + cwd, + projectName, + files: projectFiles.js, + }) + + projectDepsShouldBe({ + type: 'dependencies', + cwd, + projectName, + deps: projectDeps.ts, + }) + + projectDepsShouldBe({ + type: 'devDependencies', + cwd, + projectName, + deps: projectDevDeps.ts, + }) +} diff --git a/test/integration/create-next-app/templates.test.ts b/test/integration/create-next-app/templates.test.ts new file mode 100644 index 0000000000000..7dee078f076b6 --- /dev/null +++ b/test/integration/create-next-app/templates.test.ts @@ -0,0 +1,88 @@ +/* eslint-env jest */ +/** + * @fileoverview + * + * This file contains tests for `create-next-app` templates, currently + * JavaScript (default), TypeScript, and appDir. + */ + +import { + createNextApp, + projectFilesShouldNotExist, + shouldBeJavascriptProject, + shouldBeAppProject, + spawnExitPromise, + shouldBeTypescriptProject, +} from './lib/utils' + +import { useTempDir } from '../../../test/lib/use-temp-dir' + +describe('create-next-app templates', () => { + it('should prompt user to choose if --ts or --js is not provided', async () => { + useTempDir(async (cwd) => { + const projectName = 'choose-ts-js' + + /** + * Start the create-next-app call. + */ + const childProcess = createNextApp([projectName], { cwd }) + /** + * Wait for the prompt to display. + */ + // await new Promise((resolve) => setTimeout(resolve, 1000)); + /** + * Bind the exit listener. + */ + childProcess.on('exit', (exitCode) => { + expect(exitCode).toBe(0) + /** + * Verify it correctly emitted a TS project by looking for tsconfig. + */ + projectFilesShouldNotExist({ + cwd, + projectName, + files: ['tsconfig.json'], + }) + }) + /** + * Simulate "Y" for TypeScript. + */ + childProcess.stdin.write('N\n') + }) + }) + + it('should create TS projects with --ts, --typescript', async () => { + await useTempDir(async (cwd) => { + const projectName = 'typescript-test' + const childProcess = createNextApp([projectName, '--ts'], { cwd }) + const exitCode = await spawnExitPromise(childProcess) + + expect(exitCode).toBe(0) + shouldBeTypescriptProject({ cwd, projectName }) + }) + }) + + it('should create JS projects with --js, --javascript', async () => { + await useTempDir(async (cwd) => { + const projectName = 'javascript-test' + const childProcess = createNextApp([projectName, '--js'], { cwd }) + const exitCode = await spawnExitPromise(childProcess) + + expect(exitCode).toBe(0) + shouldBeJavascriptProject({ cwd, projectName }) + }) + }) + + it('should create appDir projects with --experimental-app', async () => { + await useTempDir(async (cwd) => { + const projectName = 'appdir-test' + const childProcess = createNextApp([projectName, '--experimental-app'], { + cwd, + }) + + const exitCode = await spawnExitPromise(childProcess) + expect(exitCode).toBe(0) + shouldBeAppProject({ cwd, projectName }) + }) + }) +}) diff --git a/test/lib/use-temp-dir.ts b/test/lib/use-temp-dir.ts new file mode 100644 index 0000000000000..ac5ee243c8e71 --- /dev/null +++ b/test/lib/use-temp-dir.ts @@ -0,0 +1,25 @@ +import fs from 'fs-extra' +import os from 'os' +import path from 'path' + +/** + * Create a randomly-named directory in `os.tmpdir()`, await a function call, + * and delete the directory when finished. + */ +export async function useTempDir( + fn: (folder: string) => void | Promise, + mode?: string | number +) { + const folder = path.join(os.tmpdir(), Math.random().toString(36).slice(2)) + await fs.mkdirp(folder) + + if (mode) { + fs.chmod(folder, mode) + } + + try { + await fn(folder) + } finally { + await fs.remove(folder) + } +}