From a034aa1f60c62c30be7ee878b222899b67d2935c Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Fri, 25 Oct 2024 15:59:32 +0100 Subject: [PATCH] feat(create-cloudflare): prompt user to correct the answer if the arg value is invalid (#7055) --- .changeset/shiny-files-jog.md | 5 ++ packages/cli/interactive.ts | 11 ++- .../create-cloudflare/e2e-tests/cli.test.ts | 80 +++++++++++++------ .../create-cloudflare/e2e-tests/helpers.ts | 21 +++++ .../create-cloudflare/src/helpers/args.ts | 5 +- 5 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 .changeset/shiny-files-jog.md diff --git a/.changeset/shiny-files-jog.md b/.changeset/shiny-files-jog.md new file mode 100644 index 000000000000..e7fa5758d157 --- /dev/null +++ b/.changeset/shiny-files-jog.md @@ -0,0 +1,5 @@ +--- +"create-cloudflare": minor +--- + +feat: prompt user to correct the answer if the arg value is invalid diff --git a/packages/cli/interactive.ts b/packages/cli/interactive.ts index 8af97023e16b..6e6ff843392e 100644 --- a/packages/cli/interactive.ts +++ b/packages/cli/interactive.ts @@ -47,6 +47,8 @@ export type BasePromptConfig = { helpText?: string; // The value to use by default defaultValue?: Arg; + // The error message to display if the initial value is invalid + initialErrorMessage?: string | null; // Accept the initialValue/defaultValue as if the user pressed ENTER when prompted acceptDefault?: boolean; // The status label to be shown after submitting @@ -141,7 +143,14 @@ export const inputPrompt = async ( // Looks up the needed renderer by the current state ('initial', 'submitted', etc.) const dispatchRender = (props: RenderProps, p: Prompt): string | void => { - const renderedLines = renderers[props.state](props, p); + let state = props.state; + + if (state === "initial" && promptConfig.initialErrorMessage) { + state = "error"; + props.error = promptConfig.initialErrorMessage; + } + + const renderedLines = renderers[state](props, p); return renderedLines.join("\n"); }; diff --git a/packages/create-cloudflare/e2e-tests/cli.test.ts b/packages/create-cloudflare/e2e-tests/cli.test.ts index 5030d40bef6b..094d86829bc3 100644 --- a/packages/create-cloudflare/e2e-tests/cli.test.ts +++ b/packages/create-cloudflare/e2e-tests/cli.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import { basename } from "node:path"; import { beforeAll, describe, expect } from "vitest"; import { version } from "../package.json"; import { getFrameworkToTest } from "./frameworkToTest"; @@ -133,30 +135,61 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( test({ experimental }).skipIf(process.platform === "win32")( "Mixed args and interactive", async ({ logStream, project }) => { - const { output } = await runC3( - [project.path, "--ts", "--no-deploy"], - [ - { - matcher: /What would you like to start with\?/, - input: [keys.enter], - }, - { - matcher: /Which template would you like to use\?/, - input: [keys.enter], - }, - { - matcher: /Do you want to use git for version control/, - input: ["n"], - }, - ], - logStream, + const projectName = basename(project.path); + const existingProjectName = Array.from(projectName).reverse().join(""); + const existingProjectPath = project.path.replace( + projectName, + existingProjectName, ); + const existingFilePath = `${existingProjectPath}/example.json`; - expect(project.path).toExist(); - expect(output).toContain(`type Hello World Worker`); - expect(output).toContain(`lang TypeScript`); - expect(output).toContain(`no git`); - expect(output).toContain(`no deploy`); + try { + // Prepare an existing project with a file + fs.mkdirSync(existingProjectPath, { recursive: true }); + fs.writeFileSync(existingFilePath, `"Hello World"`); + + const { output } = await runC3( + [existingProjectPath, "--ts", "--no-deploy"], + [ + // c3 will ask for a new project name as the provided one already exists + { + matcher: + /In which directory do you want to create your application/, + input: { + type: "text", + chunks: [project.path, keys.enter], + assertErrorMessage: `ERROR Directory \`${existingProjectPath}\` already exists and contains files that might conflict. Please choose a new name.`, + }, + }, + { + matcher: /What would you like to start with\?/, + input: [keys.enter], + }, + { + matcher: /Which template would you like to use\?/, + input: [keys.enter], + }, + { + matcher: /Do you want to use git for version control/, + input: ["n"], + }, + ], + logStream, + ); + + expect(project.path).toExist(); + expect(output).toContain(`type Hello World Worker`); + expect(output).toContain(`lang TypeScript`); + expect(output).toContain(`no git`); + expect(output).toContain(`no deploy`); + } finally { + fs.rmSync(existingFilePath, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + } }, ); @@ -243,8 +276,9 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( test({ experimental }).skipIf(process.platform === "win32")( "Going back and forth between the category, type, framework and lang prompts", async ({ logStream, project }) => { + const testProjectPath = "/test-project-path"; const { output } = await runC3( - ["/invalid-project-name", "--git=false", "--no-deploy"], + [testProjectPath, "--git=false", "--no-deploy"], [ { matcher: /What would you like to start with\?/, diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index 491169eccf5c..b7c4b357dce2 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -53,6 +53,11 @@ export type PromptHandler = { matcher: RegExp; input: | string[] + | { + type: "text"; + chunks: string[]; + assertErrorMessage?: string; + } | { type: "select"; target: RegExp | string; @@ -117,6 +122,22 @@ export const runC3 = async ( currentDialog.input.forEach((keystroke) => { proc.stdin.write(keystroke); }); + } else if (currentDialog.input.type === "text") { + // text prompt handler + const { assertErrorMessage, chunks } = currentDialog.input; + + if ( + assertErrorMessage !== undefined && + !text.includes(assertErrorMessage) + ) { + throw new Error( + `The error message does not match; Expected "${assertErrorMessage}" but found "${text}".`, + ); + } + + chunks.forEach((keystroke) => { + proc.stdin.write(keystroke); + }); } else if (currentDialog.input.type === "select") { // select prompt handler diff --git a/packages/create-cloudflare/src/helpers/args.ts b/packages/create-cloudflare/src/helpers/args.ts index a8d6639c377a..80a2886b8777 100644 --- a/packages/create-cloudflare/src/helpers/args.ts +++ b/packages/create-cloudflare/src/helpers/args.ts @@ -415,11 +415,14 @@ export const processArgument = async ( disableTelemetry: args[key] !== undefined, async promise() { const value = args[key]; + const error = promptConfig.validate?.(value) ?? null; const result = await inputPrompt[Key]>({ ...promptConfig, // Accept the default value if the arg is already set - acceptDefault: promptConfig.acceptDefault ?? value !== undefined, + acceptDefault: + promptConfig.acceptDefault ?? (value !== undefined && !error), defaultValue: value ?? promptConfig.defaultValue, + initialErrorMessage: error, throwOnError: true, });